diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_management/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sale_management/models')
| -rw-r--r-- | addons/sale_management/models/__init__.py | 7 | ||||
| -rw-r--r-- | addons/sale_management/models/digest.py | 29 | ||||
| -rw-r--r-- | addons/sale_management/models/res_company.py | 15 | ||||
| -rw-r--r-- | addons/sale_management/models/res_config_settings.py | 28 | ||||
| -rw-r--r-- | addons/sale_management/models/sale_order.py | 277 | ||||
| -rw-r--r-- | addons/sale_management/models/sale_order_template.py | 173 |
6 files changed, 529 insertions, 0 deletions
diff --git a/addons/sale_management/models/__init__.py b/addons/sale_management/models/__init__.py new file mode 100644 index 00000000..7671a85a --- /dev/null +++ b/addons/sale_management/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import digest +from . import res_company +from . import res_config_settings +from . import sale_order +from . import sale_order_template diff --git a/addons/sale_management/models/digest.py b/addons/sale_management/models/digest.py new file mode 100644 index 00000000..8828ad98 --- /dev/null +++ b/addons/sale_management/models/digest.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, _ +from odoo.exceptions import AccessError + + +class Digest(models.Model): + _inherit = 'digest.digest' + + kpi_all_sale_total = fields.Boolean('All Sales') + kpi_all_sale_total_value = fields.Monetary(compute='_compute_kpi_sale_total_value') + + def _compute_kpi_sale_total_value(self): + if not self.env.user.has_group('sales_team.group_sale_salesman_all_leads'): + raise AccessError(_("Do not have access, skip this data for user's digest email")) + for record in self: + start, end, company = record._get_kpi_compute_parameters() + all_channels_sales = self.env['sale.report'].read_group([ + ('date', '>=', start), + ('date', '<', end), + ('state', 'not in', ['draft', 'cancel', 'sent']), + ('company_id', '=', company.id)], ['price_total'], ['price_total']) + record.kpi_all_sale_total_value = sum([channel_sale['price_total'] for channel_sale in all_channels_sales]) + + def _compute_kpis_actions(self, company, user): + res = super(Digest, self)._compute_kpis_actions(company, user) + res['kpi_all_sale_total'] = 'sale.report_all_channels_sales_action&menu_id=%s' % self.env.ref('sale.sale_menu_root').id + return res diff --git a/addons/sale_management/models/res_company.py b/addons/sale_management/models/res_company.py new file mode 100644 index 00000000..ea9e139e --- /dev/null +++ b/addons/sale_management/models/res_company.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + _check_company_auto = True + + sale_order_template_id = fields.Many2one( + "sale.order.template", string="Default Sale Template", + domain="['|', ('company_id', '=', False), ('company_id', '=', id)]", + check_company=True, + ) diff --git a/addons/sale_management/models/res_config_settings.py b/addons/sale_management/models/res_config_settings.py new file mode 100644 index 00000000..bdfc0e0d --- /dev/null +++ b/addons/sale_management/models/res_config_settings.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, api + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + group_sale_order_template = fields.Boolean( + "Quotation Templates", implied_group='sale_management.group_sale_order_template') + company_so_template_id = fields.Many2one( + related="company_id.sale_order_template_id", string="Default Template", readonly=False, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + module_sale_quotation_builder = fields.Boolean("Quotation Builder") + + @api.onchange('group_sale_order_template') + def _onchange_group_sale_order_template(self): + if not self.group_sale_order_template: + self.module_sale_quotation_builder = False + + def set_values(self): + if not self.group_sale_order_template: + self.company_so_template_id = None + self.env['res.company'].sudo().search([]).write({ + 'sale_order_template_id': False, + }) + return super(ResConfigSettings, self).set_values() diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py new file mode 100644 index 00000000..165d5acf --- /dev/null +++ b/addons/sale_management/models/sale_order.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + @api.model + def default_get(self, fields_list): + default_vals = super(SaleOrder, self).default_get(fields_list) + if "sale_order_template_id" in fields_list and not default_vals.get("sale_order_template_id"): + company_id = default_vals.get('company_id', False) + company = self.env["res.company"].browse(company_id) if company_id else self.env.company + default_vals['sale_order_template_id'] = company.sale_order_template_id.id + return default_vals + + sale_order_template_id = fields.Many2one( + 'sale.order.template', 'Quotation Template', + readonly=True, check_company=True, + states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + sale_order_option_ids = fields.One2many( + 'sale.order.option', 'order_id', 'Optional Products Lines', + copy=True, readonly=True, + states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}) + + @api.constrains('company_id', 'sale_order_option_ids') + def _check_optional_product_company_id(self): + for order in self: + companies = order.sale_order_option_ids.product_id.company_id + if companies and companies != order.company_id: + bad_products = order.sale_order_option_ids.product_id.filtered(lambda p: p.company_id and p.company_id != order.company_id) + raise ValidationError(_( + "Your quotation contains products from company %(product_company)s whereas your quotation belongs to company %(quote_company)s. \n Please change the company of your quotation or remove the products from other companies (%(bad_products)s).", + product_company=', '.join(companies.mapped('display_name')), + quote_company=order.company_id.display_name, + bad_products=', '.join(bad_products.mapped('display_name')), + )) + + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + if self.sale_order_template_id and self.sale_order_template_id.number_of_days > 0: + default = dict(default or {}) + default['validity_date'] = fields.Date.context_today(self) + timedelta(self.sale_order_template_id.number_of_days) + return super(SaleOrder, self).copy(default=default) + + @api.onchange('partner_id') + def onchange_partner_id(self): + super(SaleOrder, self).onchange_partner_id() + template = self.sale_order_template_id.with_context(lang=self.partner_id.lang) + self.note = template.note or self.note + + def _compute_line_data_for_template_change(self, line): + return { + 'display_type': line.display_type, + 'name': line.name, + 'state': 'draft', + } + + def _compute_option_data_for_template_change(self, option): + price = option.product_id.lst_price + discount = 0 + + if self.pricelist_id: + pricelist_price = self.pricelist_id.with_context(uom=option.uom_id.id).get_product_price(option.product_id, 1, False) + + if self.pricelist_id.discount_policy == 'without_discount' and price: + discount = max(0, (price - pricelist_price) * 100 / price) + else: + price = pricelist_price + + return { + 'product_id': option.product_id.id, + 'name': option.name, + 'quantity': option.quantity, + 'uom_id': option.uom_id.id, + 'price_unit': price, + 'discount': discount + } + + def update_prices(self): + self.ensure_one() + res = super().update_prices() + for line in self.sale_order_option_ids: + line.price_unit = self.pricelist_id.get_product_price(line.product_id, line.quantity, self.partner_id, uom_id=line.uom_id.id) + return res + + @api.onchange('sale_order_template_id') + def onchange_sale_order_template_id(self): + + if not self.sale_order_template_id: + self.require_signature = self._get_default_require_signature() + self.require_payment = self._get_default_require_payment() + return + + template = self.sale_order_template_id.with_context(lang=self.partner_id.lang) + + # --- first, process the list of products from the template + order_lines = [(5, 0, 0)] + for line in template.sale_order_template_line_ids: + data = self._compute_line_data_for_template_change(line) + + if line.product_id: + price = line.product_id.lst_price + discount = 0 + + if self.pricelist_id: + pricelist_price = self.pricelist_id.with_context(uom=line.product_uom_id.id).get_product_price(line.product_id, 1, False) + + if self.pricelist_id.discount_policy == 'without_discount' and price: + discount = max(0, (price - pricelist_price) * 100 / price) + else: + price = pricelist_price + + data.update({ + 'price_unit': price, + 'discount': discount, + 'product_uom_qty': line.product_uom_qty, + 'product_id': line.product_id.id, + 'product_uom': line.product_uom_id.id, + 'customer_lead': self._get_customer_lead(line.product_id.product_tmpl_id), + }) + + order_lines.append((0, 0, data)) + + self.order_line = order_lines + self.order_line._compute_tax_id() + + # then, process the list of optional products from the template + option_lines = [(5, 0, 0)] + for option in template.sale_order_template_option_ids: + data = self._compute_option_data_for_template_change(option) + option_lines.append((0, 0, data)) + + self.sale_order_option_ids = option_lines + + if template.number_of_days > 0: + self.validity_date = fields.Date.context_today(self) + timedelta(template.number_of_days) + + self.require_signature = template.require_signature + self.require_payment = template.require_payment + + if template.note: + self.note = template.note + + def action_confirm(self): + res = super(SaleOrder, self).action_confirm() + for order in self: + if order.sale_order_template_id and order.sale_order_template_id.mail_template_id: + self.sale_order_template_id.mail_template_id.send_mail(order.id) + return res + + def get_access_action(self, access_uid=None): + """ Instead of the classic form view, redirect to the online quote if it exists. """ + self.ensure_one() + user = access_uid and self.env['res.users'].sudo().browse(access_uid) or self.env.user + + if not self.sale_order_template_id or (not user.share and not self.env.context.get('force_website')): + return super(SaleOrder, self).get_access_action(access_uid) + return { + 'type': 'ir.actions.act_url', + 'url': self.get_portal_url(), + 'target': 'self', + 'res_id': self.id, + } + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + _description = "Sales Order Line" + + sale_order_option_ids = fields.One2many('sale.order.option', 'line_id', 'Optional Products Lines') + + # Take the description on the order template if the product is present in it + @api.onchange('product_id') + def product_id_change(self): + domain = super(SaleOrderLine, self).product_id_change() + if self.product_id and self.order_id.sale_order_template_id: + for line in self.order_id.sale_order_template_id.sale_order_template_line_ids: + if line.product_id == self.product_id: + self.name = line.with_context(lang=self.order_id.partner_id.lang).name + self._get_sale_order_line_multiline_description_variants() + break + return domain + + +class SaleOrderOption(models.Model): + _name = "sale.order.option" + _description = "Sale Options" + _order = 'sequence, id' + + is_present = fields.Boolean(string="Present on Quotation", + help="This field will be checked if the option line's product is " + "already present in the quotation.", + compute="_compute_is_present", search="_search_is_present") + order_id = fields.Many2one('sale.order', 'Sales Order Reference', ondelete='cascade', index=True) + line_id = fields.Many2one('sale.order.line', ondelete="set null", copy=False) + name = fields.Text('Description', required=True) + product_id = fields.Many2one('product.product', 'Product', required=True, domain=[('sale_ok', '=', True)]) + price_unit = fields.Float('Unit Price', required=True, digits='Product Price') + discount = fields.Float('Discount (%)', digits='Discount') + uom_id = fields.Many2one('uom.uom', 'Unit of Measure ', required=True, domain="[('category_id', '=', product_uom_category_id)]") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) + quantity = fields.Float('Quantity', required=True, digits='Product Unit of Measure', default=1) + sequence = fields.Integer('Sequence', help="Gives the sequence order when displaying a list of optional products.") + + @api.depends('line_id', 'order_id.order_line', 'product_id') + def _compute_is_present(self): + # NOTE: this field cannot be stored as the line_id is usually removed + # through cascade deletion, which means the compute would be false + for option in self: + option.is_present = bool(option.order_id.order_line.filtered(lambda l: l.product_id == option.product_id)) + + def _search_is_present(self, operator, value): + if (operator, value) in [('=', True), ('!=', False)]: + return [('line_id', '=', False)] + return [('line_id', '!=', False)] + + @api.onchange('product_id', 'uom_id', 'quantity') + def _onchange_product_id(self): + if not self.product_id: + return + product = self.product_id.with_context( + lang=self.order_id.partner_id.lang, + partner=self.order_id.partner_id, + quantity=self.quantity, + date=self.order_id.date_order, + pricelist=self.order_id.pricelist_id.id, + uom=self.uom_id.id, + fiscal_position=self.env.context.get('fiscal_position') + ) + self.name = product.get_product_multiline_description_sale() + self.uom_id = self.uom_id or product.uom_id + # To compute the discount a so line is created in cache + values = self._get_values_to_add_to_order() + new_sol = self.env['sale.order.line'].new(values) + new_sol._onchange_discount() + self.discount = new_sol.discount + if self.order_id.pricelist_id and self.order_id.partner_id: + self.price_unit = new_sol._get_display_price(product) + + def button_add_to_order(self): + self.add_option_to_order() + + def add_option_to_order(self): + self.ensure_one() + + sale_order = self.order_id + + if sale_order.state not in ['draft', 'sent']: + raise UserError(_('You cannot add options to a confirmed order.')) + + values = self._get_values_to_add_to_order() + order_line = self.env['sale.order.line'].create(values) + order_line._compute_tax_id() + + self.write({'line_id': order_line.id}) + if sale_order: + sale_order.add_option_to_order_with_taxcloud() + + + def _get_values_to_add_to_order(self): + self.ensure_one() + return { + 'order_id': self.order_id.id, + 'price_unit': self.price_unit, + 'name': self.name, + 'product_id': self.product_id.id, + 'product_uom_qty': self.quantity, + 'product_uom': self.uom_id.id, + 'discount': self.discount, + 'company_id': self.order_id.company_id.id, + } diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py new file mode 100644 index 00000000..f1cdeb1b --- /dev/null +++ b/addons/sale_management/models/sale_order_template.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError + + +class SaleOrderTemplate(models.Model): + _name = "sale.order.template" + _description = "Quotation Template" + + def _get_default_require_signature(self): + return self.env.company.portal_confirmation_sign + + def _get_default_require_payment(self): + return self.env.company.portal_confirmation_pay + + name = fields.Char('Quotation Template', required=True) + sale_order_template_line_ids = fields.One2many('sale.order.template.line', 'sale_order_template_id', 'Lines', copy=True) + note = fields.Text('Terms and conditions', translate=True) + sale_order_template_option_ids = fields.One2many('sale.order.template.option', 'sale_order_template_id', 'Optional Products', copy=True) + number_of_days = fields.Integer('Quotation Duration', + help='Number of days for the validity date computation of the quotation') + require_signature = fields.Boolean('Online Signature', default=_get_default_require_signature, help='Request a online signature to the customer in order to confirm orders automatically.') + require_payment = fields.Boolean('Online Payment', default=_get_default_require_payment, help='Request an online payment to the customer in order to confirm orders automatically.') + mail_template_id = fields.Many2one( + 'mail.template', 'Confirmation Mail', + domain=[('model', '=', 'sale.order')], + help="This e-mail template will be sent on confirmation. Leave empty to send nothing.") + active = fields.Boolean(default=True, help="If unchecked, it will allow you to hide the quotation template without removing it.") + company_id = fields.Many2one('res.company', string='Company') + + @api.constrains('company_id', 'sale_order_template_line_ids', 'sale_order_template_option_ids') + def _check_company_id(self): + for template in self: + companies = template.mapped('sale_order_template_line_ids.product_id.company_id') | template.mapped('sale_order_template_option_ids.product_id.company_id') + if len(companies) > 1: + raise ValidationError(_("Your template cannot contain products from multiple companies.")) + elif companies and companies != template.company_id: + raise ValidationError(_( + "Your template contains products from company %(product_company)s whereas your template belongs to company %(template_company)s. \n Please change the company of your template or remove the products from other companies.", + product_company=', '.join(companies.mapped('display_name')), + template_company=template.company_id.display_name, + )) + + @api.onchange('sale_order_template_line_ids', 'sale_order_template_option_ids') + def _onchange_template_line_ids(self): + companies = self.mapped('sale_order_template_option_ids.product_id.company_id') | self.mapped('sale_order_template_line_ids.product_id.company_id') + if companies and self.company_id not in companies: + self.company_id = companies[0] + + @api.model_create_multi + def create(self, vals_list): + records = super(SaleOrderTemplate, self).create(vals_list) + records._update_product_translations() + return records + + def write(self, vals): + if 'active' in vals and not vals.get('active'): + companies = self.env['res.company'].sudo().search([('sale_order_template_id', 'in', self.ids)]) + companies.sale_order_template_id = None + result = super(SaleOrderTemplate, self).write(vals) + self._update_product_translations() + return result + + def _update_product_translations(self): + languages = self.env['res.lang'].search([('active', '=', 'true')]) + for lang in languages: + for line in self.sale_order_template_line_ids: + if line.name == line.product_id.get_product_multiline_description_sale(): + self.create_or_update_translations(model_name='sale.order.template.line,name', lang_code=lang.code, + res_id=line.id,src=line.name, + value=line.product_id.with_context(lang=lang.code).get_product_multiline_description_sale()) + for option in self.sale_order_template_option_ids: + if option.name == option.product_id.get_product_multiline_description_sale(): + self.create_or_update_translations(model_name='sale.order.template.option,name', lang_code=lang.code, + res_id=option.id,src=option.name, + value=option.product_id.with_context(lang=lang.code).get_product_multiline_description_sale()) + + def create_or_update_translations(self, model_name, lang_code, res_id, src, value): + data = { + 'type': 'model', + 'name': model_name, + 'lang': lang_code, + 'res_id': res_id, + 'src': src, + 'value': value, + 'state': 'inprogress', + } + existing_trans = self.env['ir.translation'].search([('name', '=', model_name), + ('res_id', '=', res_id), + ('lang', '=', lang_code)]) + if not existing_trans: + self.env['ir.translation'].create(data) + else: + existing_trans.write(data) + + + +class SaleOrderTemplateLine(models.Model): + _name = "sale.order.template.line" + _description = "Quotation Template Line" + _order = 'sale_order_template_id, sequence, id' + + sequence = fields.Integer('Sequence', help="Gives the sequence order when displaying a list of sale quote lines.", + default=10) + sale_order_template_id = fields.Many2one( + 'sale.order.template', 'Quotation Template Reference', + required=True, ondelete='cascade', index=True) + company_id = fields.Many2one('res.company', related='sale_order_template_id.company_id', store=True, index=True) + name = fields.Text('Description', required=True, translate=True) + product_id = fields.Many2one( + 'product.product', 'Product', check_company=True, + domain=[('sale_ok', '=', True)]) + product_uom_qty = fields.Float('Quantity', required=True, digits='Product Unit of Measure', default=1) + product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) + + display_type = fields.Selection([ + ('line_section', "Section"), + ('line_note', "Note")], default=False, help="Technical field for UX purpose.") + + @api.onchange('product_id') + def _onchange_product_id(self): + self.ensure_one() + if self.product_id: + self.product_uom_id = self.product_id.uom_id.id + self.name = self.product_id.get_product_multiline_description_sale() + + @api.model + def create(self, values): + if values.get('display_type', self.default_get(['display_type'])['display_type']): + values.update(product_id=False, product_uom_qty=0, product_uom_id=False) + return super(SaleOrderTemplateLine, self).create(values) + + def write(self, values): + if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): + raise UserError(_("You cannot change the type of a sale quote line. Instead you should delete the current line and create a new line of the proper type.")) + return super(SaleOrderTemplateLine, self).write(values) + + _sql_constraints = [ + ('accountable_product_id_required', + "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom_id IS NOT NULL))", + "Missing required product and UoM on accountable sale quote line."), + + ('non_accountable_fields_null', + "CHECK(display_type IS NULL OR (product_id IS NULL AND product_uom_qty = 0 AND product_uom_id IS NULL))", + "Forbidden product, unit price, quantity, and UoM on non-accountable sale quote line"), + ] + + +class SaleOrderTemplateOption(models.Model): + _name = "sale.order.template.option" + _description = "Quotation Template Option" + _check_company_auto = True + + sale_order_template_id = fields.Many2one('sale.order.template', 'Quotation Template Reference', ondelete='cascade', + index=True, required=True) + company_id = fields.Many2one('res.company', related='sale_order_template_id.company_id', store=True, index=True) + name = fields.Text('Description', required=True, translate=True) + product_id = fields.Many2one( + 'product.product', 'Product', domain=[('sale_ok', '=', True)], + required=True, check_company=True) + uom_id = fields.Many2one('uom.uom', 'Unit of Measure ', required=True, domain="[('category_id', '=', product_uom_category_id)]") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) + quantity = fields.Float('Quantity', required=True, digits='Product Unit of Measure', default=1) + + @api.onchange('product_id') + def _onchange_product_id(self): + if not self.product_id: + return + self.uom_id = self.product_id.uom_id + self.name = self.product_id.get_product_multiline_description_sale() |
