diff options
Diffstat (limited to 'addons/sale_purchase/models')
| -rw-r--r-- | addons/sale_purchase/models/__init__.py | 6 | ||||
| -rw-r--r-- | addons/sale_purchase/models/product_template.py | 26 | ||||
| -rw-r--r-- | addons/sale_purchase/models/purchase_order.py | 74 | ||||
| -rw-r--r-- | addons/sale_purchase/models/sale_order.py | 316 |
4 files changed, 422 insertions, 0 deletions
diff --git a/addons/sale_purchase/models/__init__.py b/addons/sale_purchase/models/__init__.py new file mode 100644 index 00000000..99593ce8 --- /dev/null +++ b/addons/sale_purchase/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import product_template +from . import sale_order +from . import purchase_order diff --git a/addons/sale_purchase/models/product_template.py b/addons/sale_purchase/models/product_template.py new file mode 100644 index 00000000..ba7808f3 --- /dev/null +++ b/addons/sale_purchase/models/product_template.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + service_to_purchase = fields.Boolean("Purchase Automatically", help="If ticked, each time you sell this product through a SO, a RfQ is automatically created to buy the product. Tip: don't forget to set a vendor on the product.") + + _sql_constraints = [ + ('service_to_purchase', "CHECK((type != 'service' AND service_to_purchase != true) or (type = 'service'))", 'Product that is not a service can not create RFQ.'), + ] + + @api.onchange('type') + def _onchange_type(self): + res = super(ProductTemplate, self)._onchange_type() + if self.type != 'service': + self.service_to_purchase = False + return res + + @api.onchange('expense_policy') + def _onchange_expense_policy(self): + if self.expense_policy != 'no': + self.service_to_purchase = False diff --git a/addons/sale_purchase/models/purchase_order.py b/addons/sale_purchase/models/purchase_order.py new file mode 100644 index 00000000..e4bd496b --- /dev/null +++ b/addons/sale_purchase/models/purchase_order.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + sale_order_count = fields.Integer( + "Number of Source Sale", + compute='_compute_sale_order_count', + groups='sales_team.group_sale_salesman') + + @api.depends('order_line.sale_order_id') + def _compute_sale_order_count(self): + for purchase in self: + purchase.sale_order_count = len(purchase._get_sale_orders()) + + def action_view_sale_orders(self): + self.ensure_one() + sale_order_ids = self._get_sale_orders().ids + action = { + 'res_model': 'sale.order', + 'type': 'ir.actions.act_window', + } + if len(sale_order_ids) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': sale_order_ids[0], + }) + else: + action.update({ + 'name': _('Sources Sale Orders %s', self.name), + 'domain': [('id', 'in', sale_order_ids)], + 'view_mode': 'tree,form', + }) + return action + + def button_cancel(self): + result = super(PurchaseOrder, self).button_cancel() + self.sudo()._activity_cancel_on_sale() + return result + + def _get_sale_orders(self): + return self.order_line.sale_order_id + + def _activity_cancel_on_sale(self): + """ If some PO are cancelled, we need to put an activity on their origin SO (only the open ones). Since a PO can have + been modified by several SO, when cancelling one PO, many next activities can be schedulded on different SO. + """ + sale_to_notify_map = {} # map SO -> recordset of PO as {sale.order: set(purchase.order.line)} + for order in self: + for purchase_line in order.order_line: + if purchase_line.sale_line_id: + sale_order = purchase_line.sale_line_id.order_id + sale_to_notify_map.setdefault(sale_order, self.env['purchase.order.line']) + sale_to_notify_map[sale_order] |= purchase_line + + for sale_order, purchase_order_lines in sale_to_notify_map.items(): + sale_order._activity_schedule_with_view('mail.mail_activity_data_warning', + user_id=sale_order.user_id.id or self.env.uid, + views_or_xmlid='sale_purchase.exception_sale_on_purchase_cancellation', + render_context={ + 'purchase_orders': purchase_order_lines.mapped('order_id'), + 'purchase_order_lines': purchase_order_lines, + }) + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + sale_order_id = fields.Many2one(related='sale_line_id.order_id', string="Sale Order", store=True, readonly=True) + sale_line_id = fields.Many2one('sale.order.line', string="Origin Sale Item", index=True) diff --git a/addons/sale_purchase/models/sale_order.py b/addons/sale_purchase/models/sale_order.py new file mode 100644 index 00000000..a9d6ae19 --- /dev/null +++ b/addons/sale_purchase/models/sale_order.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import float_compare + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + purchase_order_count = fields.Integer( + "Number of Purchase Order Generated", + compute='_compute_purchase_order_count', + groups='purchase.group_purchase_user') + + @api.depends('order_line.purchase_line_ids.order_id') + def _compute_purchase_order_count(self): + for order in self: + order.purchase_order_count = len(self._get_purchase_orders()) + + def _action_confirm(self): + result = super(SaleOrder, self)._action_confirm() + for order in self: + order.order_line.sudo()._purchase_service_generation() + return result + + def action_cancel(self): + result = super(SaleOrder, self).action_cancel() + # When a sale person cancel a SO, he might not have the rights to write + # on PO. But we need the system to create an activity on the PO (so 'write' + # access), hence the `sudo`. + self.sudo()._activity_cancel_on_purchase() + return result + + def action_view_purchase_orders(self): + self.ensure_one() + purchase_order_ids = self._get_purchase_orders().ids + action = { + 'res_model': 'purchase.order', + 'type': 'ir.actions.act_window', + } + if len(purchase_order_ids) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': purchase_order_ids[0], + }) + else: + action.update({ + 'name': _("Purchase Order generated from %s", self.name), + 'domain': [('id', 'in', purchase_order_ids)], + 'view_mode': 'tree,form', + }) + return action + + def _get_purchase_orders(self): + return self.order_line.purchase_line_ids.order_id + + def _activity_cancel_on_purchase(self): + """ If some SO are cancelled, we need to put an activity on their generated purchase. If sale lines of + different sale orders impact different purchase, we only want one activity to be attached. + """ + purchase_to_notify_map = {} # map PO -> recordset of SOL as {purchase.order: set(sale.orde.liner)} + + purchase_order_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.mapped('order_line').ids), ('state', '!=', 'cancel')]) + for purchase_line in purchase_order_lines: + purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line']) + purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id + + for purchase_order, sale_order_lines in purchase_to_notify_map.items(): + purchase_order._activity_schedule_with_view('mail.mail_activity_data_warning', + user_id=purchase_order.user_id.id or self.env.uid, + views_or_xmlid='sale_purchase.exception_purchase_on_sale_cancellation', + render_context={ + 'sale_orders': sale_order_lines.mapped('order_id'), + 'sale_order_lines': sale_order_lines, + }) + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.") + purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count') + + @api.depends('purchase_line_ids') + def _compute_purchase_count(self): + database_data = self.env['purchase.order.line'].sudo().read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id']) + mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data]) + for line in self: + line.purchase_line_count = mapped_data.get(line.id, 0) + + @api.onchange('product_uom_qty') + def _onchange_service_product_uom_qty(self): + if self.state == 'sale' and self.product_id.type == 'service' and self.product_id.service_to_purchase: + if self.product_uom_qty < self._origin.product_uom_qty: + if self.product_uom_qty < self.qty_delivered: + return {} + warning_mess = { + 'title': _('Ordered quantity decreased!'), + 'message': _('You are decreasing the ordered quantity! Do not forget to manually update the purchase order if needed.'), + } + return {'warning': warning_mess} + return {} + + # -------------------------- + # CRUD + # -------------------------- + + @api.model_create_multi + def create(self, values): + lines = super(SaleOrderLine, self).create(values) + # Do not generate purchase when expense SO line since the product is already delivered + lines.filtered( + lambda line: line.state == 'sale' and not line.is_expense + )._purchase_service_generation() + return lines + + def write(self, values): + increased_lines = None + decreased_lines = None + increased_values = {} + decreased_values = {} + if 'product_uom_qty' in values: + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + increased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) + decreased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == 1) + increased_values = {line.id: line.product_uom_qty for line in increased_lines} + decreased_values = {line.id: line.product_uom_qty for line in decreased_lines} + + result = super(SaleOrderLine, self).write(values) + + if increased_lines: + increased_lines._purchase_increase_ordered_qty(values['product_uom_qty'], increased_values) + if decreased_lines: + decreased_lines._purchase_decrease_ordered_qty(values['product_uom_qty'], decreased_values) + return result + + # -------------------------- + # Business Methods + # -------------------------- + + def _purchase_decrease_ordered_qty(self, new_qty, origin_values): + """ Decrease the quantity from SO line will add a next acitivities on the related purchase order + :param new_qty: new quantity (lower than the current one on SO line), expressed + in UoM of SO line. + :param origin_values: map from sale line id to old value for the ordered quantity (dict) + """ + purchase_to_notify_map = {} # map PO -> set(SOL) + last_purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.ids)]) + for purchase_line in last_purchase_lines: + purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line']) + purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id + + # create next activity + for purchase_order, sale_lines in purchase_to_notify_map.items(): + render_context = { + 'sale_lines': sale_lines, + 'sale_orders': sale_lines.mapped('order_id'), + 'origin_values': origin_values, + } + purchase_order._activity_schedule_with_view('mail.mail_activity_data_warning', + user_id=purchase_order.user_id.id or self.env.uid, + views_or_xmlid='sale_purchase.exception_purchase_on_sale_quantity_decreased', + render_context=render_context) + + def _purchase_increase_ordered_qty(self, new_qty, origin_values): + """ Increase the quantity on the related purchase lines + :param new_qty: new quantity (higher than the current one on SO line), expressed + in UoM of SO line. + :param origin_values: map from sale line id to old value for the ordered quantity (dict) + """ + for line in self: + last_purchase_line = self.env['purchase.order.line'].search([('sale_line_id', '=', line.id)], order='create_date DESC', limit=1) + if last_purchase_line.state in ['draft', 'sent', 'to approve']: # update qty for draft PO lines + quantity = line.product_uom._compute_quantity(new_qty, last_purchase_line.product_uom) + last_purchase_line.write({'product_qty': quantity}) + elif last_purchase_line.state in ['purchase', 'done', 'cancel']: # create new PO, by forcing the quantity as the difference from SO line + quantity = line.product_uom._compute_quantity(new_qty - origin_values.get(line.id, 0.0), last_purchase_line.product_uom) + line._purchase_service_create(quantity=quantity) + + def _purchase_get_date_order(self, supplierinfo): + """ return the ordered date for the purchase order, computed as : SO commitment date - supplier delay """ + commitment_date = fields.Datetime.from_string(self.order_id.commitment_date or fields.Datetime.now()) + return commitment_date - relativedelta(days=int(supplierinfo.delay)) + + def _purchase_service_prepare_order_values(self, supplierinfo): + """ Returns the values to create the purchase order from the current SO line. + :param supplierinfo: record of product.supplierinfo + :rtype: dict + """ + self.ensure_one() + partner_supplier = supplierinfo.name + fpos = self.env['account.fiscal.position'].sudo().get_fiscal_position(partner_supplier.id) + date_order = self._purchase_get_date_order(supplierinfo) + return { + 'partner_id': partner_supplier.id, + 'partner_ref': partner_supplier.ref, + 'company_id': self.company_id.id, + 'currency_id': partner_supplier.property_purchase_currency_id.id or self.env.company.currency_id.id, + 'dest_address_id': False, # False since only supported in stock + 'origin': self.order_id.name, + 'payment_term_id': partner_supplier.property_supplier_payment_term_id.id, + 'date_order': date_order, + 'fiscal_position_id': fpos.id, + } + + def _purchase_service_prepare_line_values(self, purchase_order, quantity=False): + """ Returns the values to create the purchase order line from the current SO line. + :param purchase_order: record of purchase.order + :rtype: dict + :param quantity: the quantity to force on the PO line, expressed in SO line UoM + """ + self.ensure_one() + # compute quantity from SO line UoM + product_quantity = self.product_uom_qty + if quantity: + product_quantity = quantity + + purchase_qty_uom = self.product_uom._compute_quantity(product_quantity, self.product_id.uom_po_id) + + # determine vendor (real supplier, sharing the same partner as the one from the PO, but with more accurate informations like validity, quantity, ...) + # Note: one partner can have multiple supplier info for the same product + supplierinfo = self.product_id._select_seller( + partner_id=purchase_order.partner_id, + quantity=purchase_qty_uom, + date=purchase_order.date_order and purchase_order.date_order.date(), # and purchase_order.date_order[:10], + uom_id=self.product_id.uom_po_id + ) + fpos = purchase_order.fiscal_position_id + taxes = fpos.map_tax(self.product_id.supplier_taxes_id) + if taxes: + taxes = taxes.filtered(lambda t: t.company_id.id == self.company_id.id) + + # compute unit price + price_unit = 0.0 + if supplierinfo: + price_unit = self.env['account.tax'].sudo()._fix_tax_included_price_company(supplierinfo.price, self.product_id.supplier_taxes_id, taxes, self.company_id) + if purchase_order.currency_id and supplierinfo.currency_id != purchase_order.currency_id: + price_unit = supplierinfo.currency_id.compute(price_unit, purchase_order.currency_id) + + return { + 'name': '[%s] %s' % (self.product_id.default_code, self.name) if self.product_id.default_code else self.name, + 'product_qty': purchase_qty_uom, + 'product_id': self.product_id.id, + 'product_uom': self.product_id.uom_po_id.id, + 'price_unit': price_unit, + 'date_planned': fields.Date.from_string(purchase_order.date_order) + relativedelta(days=int(supplierinfo.delay)), + 'taxes_id': [(6, 0, taxes.ids)], + 'order_id': purchase_order.id, + 'sale_line_id': self.id, + } + + def _purchase_service_create(self, quantity=False): + """ On Sales Order confirmation, some lines (services ones) can create a purchase order line and maybe a purchase order. + If a line should create a RFQ, it will check for existing PO. If no one is find, the SO line will create one, then adds + a new PO line. The created purchase order line will be linked to the SO line. + :param quantity: the quantity to force on the PO line, expressed in SO line UoM + """ + PurchaseOrder = self.env['purchase.order'] + supplier_po_map = {} + sale_line_purchase_map = {} + for line in self: + line = line.with_company(line.company_id) + # determine vendor of the order (take the first matching company and product) + suppliers = line.product_id._select_seller(quantity=line.product_uom_qty, uom_id=line.product_uom) + if not suppliers: + raise UserError(_("There is no vendor associated to the product %s. Please define a vendor for this product.") % (line.product_id.display_name,)) + supplierinfo = suppliers[0] + partner_supplier = supplierinfo.name # yes, this field is not explicit .... it is a res.partner ! + + # determine (or create) PO + purchase_order = supplier_po_map.get(partner_supplier.id) + if not purchase_order: + purchase_order = PurchaseOrder.search([ + ('partner_id', '=', partner_supplier.id), + ('state', '=', 'draft'), + ('company_id', '=', line.company_id.id), + ], limit=1) + if not purchase_order: + values = line._purchase_service_prepare_order_values(supplierinfo) + purchase_order = PurchaseOrder.create(values) + else: # update origin of existing PO + so_name = line.order_id.name + origins = [] + if purchase_order.origin: + origins = purchase_order.origin.split(', ') + origins + if so_name not in origins: + origins += [so_name] + purchase_order.write({ + 'origin': ', '.join(origins) + }) + supplier_po_map[partner_supplier.id] = purchase_order + + # add a PO line to the PO + values = line._purchase_service_prepare_line_values(purchase_order, quantity=quantity) + purchase_line = line.env['purchase.order.line'].create(values) + + # link the generated purchase to the SO line + sale_line_purchase_map.setdefault(line, line.env['purchase.order.line']) + sale_line_purchase_map[line] |= purchase_line + return sale_line_purchase_map + + def _purchase_service_generation(self): + """ Create a Purchase for the first time from the sale line. If the SO line already created a PO, it + will not create a second one. + """ + sale_line_purchase_map = {} + for line in self: + # Do not regenerate PO line if the SO line has already created one in the past (SO cancel/reconfirmation case) + if line.product_id.service_to_purchase and not line.purchase_line_count: + result = line._purchase_service_create() + sale_line_purchase_map.update(result) + return sale_line_purchase_map |
