Odoo18-Base/addons/point_of_sale/models/product.py
2025-01-06 10:57:38 +07:00

384 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from collections import defaultdict
from itertools import groupby
from operator import itemgetter
from datetime import date
from odoo.osv.expression import AND
class ProductTemplate(models.Model):
_inherit = 'product.template'
available_in_pos = fields.Boolean(string='Available in POS', help='Check if you want this product to appear in the Point of Sale.', default=False)
to_weight = fields.Boolean(string='To Weigh With Scale', help="Check if the product should be weighted using the hardware scale integration.")
pos_categ_ids = fields.Many2many(
'pos.category', string='Point of Sale Category',
help="Category used in the Point of Sale.")
public_description = fields.Html(
string="Product Description",
translate=True
)
@api.ondelete(at_uninstall=False)
def _unlink_except_open_session(self):
product_ctx = dict(self.env.context or {}, active_test=False)
if self.with_context(product_ctx).search_count([('id', 'in', self.ids), ('available_in_pos', '=', True)]):
if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
raise UserError(_(
"To delete a product, make sure all point of sale sessions are closed.\n\n"
"Deleting a product available in a session would be like attempting to snatch a hamburger from a customers hand mid-bite; chaos will ensue as ketchup and mayo go flying everywhere!",
))
@api.onchange('sale_ok')
def _onchange_sale_ok(self):
if not self.sale_ok:
self.available_in_pos = False
@api.onchange('available_in_pos')
def _onchange_available_in_pos(self):
if self.available_in_pos and not self.sale_ok:
self.sale_ok = True
@api.constrains('available_in_pos')
def _check_combo_inclusions(self):
for product in self:
if not product.available_in_pos:
combo_name = self.env['product.combo.item'].sudo().search([('product_id', 'in', product.product_variant_ids.ids)], limit=1).combo_id.name
if combo_name:
raise UserError(_('You must first remove this product from the %s combo', combo_name))
class ProductProduct(models.Model):
_name = 'product.product'
_inherit = ['product.product', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data):
config_id = self.env['pos.config'].browse(data['pos.config']['data'][0]['id'])
return config_id._get_available_product_domain()
@api.model
def _load_pos_data_fields(self, config_id):
return [
'id', 'display_name', 'lst_price', 'standard_price', 'categ_id', 'pos_categ_ids', 'taxes_id', 'barcode', 'name',
'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', 'product_tmpl_id', 'tracking', 'type', 'service_tracking', 'is_storable',
'write_date', 'available_in_pos', 'attribute_line_ids', 'active', 'image_128', 'combo_ids', 'product_template_variant_value_ids', 'product_tag_ids',
]
def _load_pos_data(self, data):
# Add custom fields for 'formula' taxes.
fields = set(self._load_pos_data_fields(data['pos.config']['data'][0]['id']))
taxes = self.env['account.tax'].search(self.env['account.tax']._load_pos_data_domain(data))
product_fields = taxes._eval_taxes_computation_prepare_product_fields()
fields = list(fields.union(product_fields))
config = self.env['pos.config'].browse(data['pos.config']['data'][0]['id'])
limit_count = config.get_limited_product_count()
if limit_count:
products = config.with_context(display_default_code=False).get_limited_products_loading(fields)
else:
domain = self._load_pos_data_domain(data)
products = self._load_product_with_domain(domain, config.id)
self._add_missing_products(products, config.id, data)
data['pos.config']['data'][0]['_product_default_values'] = \
self.env['account.tax']._eval_taxes_computation_prepare_product_default_values(product_fields)
self._process_pos_ui_product_product(products, config)
return {
'data': products,
'fields': fields,
}
def _add_missing_products(self, products, config_id, data):
product_ids_in_loaded_lines = {line['product_id'] for line in data['pos.order.line']['data']}
not_loaded_product_ids = product_ids_in_loaded_lines - {product['id'] for product in products}
products.extend(self._load_product_with_domain([('id', 'in', list(not_loaded_product_ids))], config_id, True))
def _load_product_with_domain(self, domain, config_id, load_archived=False):
fields = self._load_pos_data_fields(config_id)
context = {**self.env.context, 'display_default_code': False, 'active_test': not load_archived}
return self.with_context(context).search_read(
domain,
fields,
order='sequence,default_code,name',
load=False)
def _process_pos_ui_product_product(self, products, config_id):
def filter_taxes_on_company(product_taxes, taxes_by_company):
"""
Filter the list of tax ids on a single company starting from the current one.
If there is no tax in the result, it's filtered on the parent company and so
on until a non empty result is found.
"""
taxes, comp = None, self.env.company
while not taxes and comp:
taxes = list(set(product_taxes) & set(taxes_by_company[comp.id]))
comp = comp.parent_id
return taxes
taxes = self.env['account.tax'].search(self.env['account.tax']._check_company_domain(self.env.company))
# group all taxes by company in a dict where:
# - key: ID of the company
# - values: list of tax ids
taxes_by_company = defaultdict(set)
if self.env.company.parent_id:
for tax in taxes:
taxes_by_company[tax.company_id.id].add(tax.id)
loaded_product_tmpl_ids = list({p['product_tmpl_id'] for p in products})
archived_combinations = self._get_archived_combinations_per_product_tmpl_id(loaded_product_tmpl_ids)
different_currency = config_id.currency_id != self.env.company.currency_id
for product in products:
if different_currency:
product['lst_price'] = self.env.company.currency_id._convert(product['lst_price'], config_id.currency_id, self.env.company, fields.Date.today())
product['image_128'] = bool(product['image_128'])
if len(taxes_by_company) > 1 and len(product['taxes_id']) > 1:
product['taxes_id'] = filter_taxes_on_company(product['taxes_id'], taxes_by_company)
if archived_combinations.get(product['product_tmpl_id']):
product['_archived_combinations'] = archived_combinations[product['product_tmpl_id']]
def _get_archived_combinations_per_product_tmpl_id(self, product_tmpl_ids):
archived_combinations = {}
for product_tmpl in self.env['product.template'].browse(product_tmpl_ids):
archived_combinations[product_tmpl.id] = product_tmpl._get_attribute_exclusions()['archived_combinations']
return archived_combinations
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
product_ctx = dict(self.env.context or {}, active_test=False)
if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
if self.with_context(product_ctx).search_count([('id', 'in', self.ids), ('product_tmpl_id.available_in_pos', '=', True)]):
raise UserError(_(
"To delete a product, make sure all point of sale sessions are closed.\n\n"
"Deleting a product available in a session would be like attempting to snatch a hamburger from a customers hand mid-bite; chaos will ensue as ketchup and mayo go flying everywhere!",
))
def get_product_info_pos(self, price, quantity, pos_config_id):
self.ensure_one()
config = self.env['pos.config'].browse(pos_config_id)
# Tax related
taxes = self.taxes_id.compute_all(price, config.currency_id, quantity, self)
grouped_taxes = {}
for tax in taxes['taxes']:
if tax['id'] in grouped_taxes:
grouped_taxes[tax['id']]['amount'] += tax['amount']/quantity if quantity else 0
else:
grouped_taxes[tax['id']] = {
'name': tax['name'],
'amount': tax['amount']/quantity if quantity else 0
}
all_prices = {
'price_without_tax': taxes['total_excluded']/quantity if quantity else 0,
'price_with_tax': taxes['total_included']/quantity if quantity else 0,
'tax_details': list(grouped_taxes.values()),
}
# Pricelists
if config.use_pricelist:
pricelists = config.available_pricelist_ids
else:
pricelists = config.pricelist_id
price_per_pricelist_id = pricelists._price_get(self, quantity) if pricelists else False
pricelist_list = [{'name': pl.name, 'price': price_per_pricelist_id[pl.id]} for pl in pricelists]
# Warehouses
warehouse_list = [
{'id': w.id,
'name': w.name,
'available_quantity': self.with_context({'warehouse_id': w.id}).qty_available,
'forecasted_quantity': self.with_context({'warehouse_id': w.id}).virtual_available,
'uom': self.uom_name}
for w in self.env['stock.warehouse'].search([('company_id', '=', config.company_id.id)])]
if config.picking_type_id.warehouse_id:
# Sort the warehouse_list, prioritizing config.picking_type_id.warehouse_id
warehouse_list = sorted(
warehouse_list,
key=lambda w: w['id'] != config.picking_type_id.warehouse_id.id
)
# Suppliers
key = itemgetter('partner_id')
supplier_list = []
for key, group in groupby(sorted(self.seller_ids, key=key), key=key):
for s in list(group):
if not((s.date_start and s.date_start > date.today()) or (s.date_end and s.date_end < date.today()) or (s.min_qty > quantity)):
supplier_list.append({
'name': s.partner_id.name,
'delay': s.delay,
'price': s.price
})
break
# Variants
variant_list = [{'name': attribute_line.attribute_id.name,
'values': list(map(lambda attr_name: {'name': attr_name, 'search': '%s %s' % (self.name, attr_name)}, attribute_line.value_ids.mapped('name')))}
for attribute_line in self.attribute_line_ids]
return {
'all_prices': all_prices,
'pricelists': pricelist_list,
'warehouses': warehouse_list,
'suppliers': supplier_list,
'variants': variant_list
}
class ProductAttribute(models.Model):
_name = 'product.attribute'
_inherit = ['product.attribute', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config_id):
return ['name', 'display_type', 'template_value_ids', 'attribute_line_ids', 'create_variant']
class ProductAttributeCustomValue(models.Model):
_name = 'product.attribute.custom.value'
_inherit = ["product.attribute.custom.value", "pos.load.mixin"]
pos_order_line_id = fields.Many2one('pos.order.line', string="PoS Order Line", ondelete='cascade')
@api.model
def _load_pos_data_domain(self, data):
return [('pos_order_line_id', 'in', [line['id'] for line in data['pos.order.line']['data']])]
@api.model
def _load_pos_data_fields(self, config_id):
return ['custom_value', 'custom_product_template_attribute_value_id', 'pos_order_line_id']
class ProductTemplateAttributeLine(models.Model):
_name = 'product.template.attribute.line'
_inherit = ['product.template.attribute.line', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config_id):
return ['display_name', 'attribute_id', 'product_template_value_ids']
@api.model
def _load_pos_data_domain(self, data):
loaded_product_tmpl_ids = list({p['product_tmpl_id'] for p in data['product.product']['data']})
return [('product_tmpl_id', 'in', loaded_product_tmpl_ids)]
class ProductTemplateAttributeValue(models.Model):
_name = 'product.template.attribute.value'
_inherit = ['product.template.attribute.value', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data):
ptav_ids = {ptav_id for p in data['product.product']['data'] for ptav_id in p['product_template_variant_value_ids']}
ptav_ids.update({ptav_id for ptal in data['product.template.attribute.line']['data'] for ptav_id in ptal['product_template_value_ids']})
return AND([
[('ptav_active', '=', True)],
[('attribute_id', 'in', [attr['id'] for attr in data['product.attribute']['data']])],
[('id', 'in', list(ptav_ids))]
])
@api.model
def _load_pos_data_fields(self, config_id):
return ['attribute_id', 'attribute_line_id', 'product_attribute_value_id', 'price_extra', 'name', 'is_custom', 'html_color', 'image']
class ProductPackaging(models.Model):
_name = 'product.packaging'
_inherit = ['product.packaging', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data):
return AND([[('barcode', 'not in', ['', False])], [('product_id', 'in', [x['id'] for x in data['product.product']['data']])] if data else []])
@api.model
def _load_pos_data_fields(self, config_id):
return ['id', 'name', 'barcode', 'product_id', 'qty']
class UomCateg(models.Model):
_name = 'uom.category'
_inherit = ['uom.category', 'pos.load.mixin']
is_pos_groupable = fields.Boolean(string='Group Products in POS',
help="Check if you want to group products of this category in point of sale orders")
@api.model
def _load_pos_data_domain(self, data):
return [('id', 'in', [uom['category_id'] for uom in data['uom.uom']['data']])]
@api.model
def _load_pos_data_fields(self, config_id):
return ['id', 'name', 'uom_ids']
class Uom(models.Model):
_name = 'uom.uom'
_inherit = ['uom.uom', 'pos.load.mixin']
is_pos_groupable = fields.Boolean(related='category_id.is_pos_groupable', readonly=False)
@api.model
def _load_pos_data_fields(self, config_id):
return ['id', 'name', 'category_id', 'factor_inv', 'factor', 'is_pos_groupable', 'uom_type', 'rounding']
def _load_pos_data(self, data):
domain = self._load_pos_data_domain(data)
fields = self._load_pos_data_fields(data['pos.config']['data'][0]['id'])
return {
'data': self.with_context({**self.env.context}).search_read(domain, fields, load=False),
'fields': fields,
}
class ProductPricelist(models.Model):
_name = 'product.pricelist'
_inherit = ['product.pricelist', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data):
config_id = self.env['pos.config'].browse(data['pos.config']['data'][0]['id'])
return [('id', 'in', config_id.available_pricelist_ids.ids)] if config_id.use_pricelist else [('id', '=', config_id.pricelist_id.id)]
@api.model
def _load_pos_data_fields(self, config_id):
return ['id', 'name', 'display_name', 'item_ids']
class ProductPricelistItem(models.Model):
_name = 'product.pricelist.item'
_inherit = ['product.pricelist.item', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data):
product_tmpl_ids = [p['product_tmpl_id'] for p in data['product.product']['data']]
product_ids = [p['id'] for p in data['product.product']['data']]
pricelist_ids = [p['id'] for p in data['product.pricelist']['data']]
return [
('pricelist_id', 'in', pricelist_ids),
'|', ('product_tmpl_id', '=', False), ('product_tmpl_id', 'in', product_tmpl_ids),
'|', ('product_id', '=', False), ('product_id', 'in', product_ids),
]
@api.model
def _load_pos_data_fields(self, config_id):
return ['product_tmpl_id', 'product_id', 'pricelist_id', 'price_surcharge', 'price_discount', 'price_round',
'price_min_margin', 'price_max_margin', 'company_id', 'currency_id', 'date_start', 'date_end', 'compute_price',
'fixed_price', 'percent_price', 'base_pricelist_id', 'base', 'categ_id', 'min_quantity']
class ProductCategory(models.Model):
_name = 'product.category'
_inherit = ['product.category', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config_id):
return ['id', 'name', 'parent_id']