From aabcdd2bc6239fca4037d10c4801739d3697f902 Mon Sep 17 00:00:00 2001 From: svs-odoo Date: Thu, 14 Sep 2023 13:32:36 +0200 Subject: [PATCH] [MOV] purchase(_stock): separate PO & PO line Separates the file `model/purchase.py` into two files: - `model/purchase_order.py` - `model/purchase_order_line.py` Since every model should have their own file, `purchase.order` and `purchase.order.line` have it now. Part-of: odoo/odoo#135502 --- .../l10n_in_purchase/models/purchase_order.py | 1 - addons/purchase/models/__init__.py | 5 +- .../models/{purchase.py => purchase_order.py} | 571 +---------------- addons/purchase/models/purchase_order_line.py | 572 ++++++++++++++++++ addons/purchase_stock/models/__init__.py | 3 +- .../purchase_stock/models/purchase_order.py | 287 +++++++++ .../{purchase.py => purchase_order_line.py} | 283 +-------- addons/purchase_stock/tests/test_uninstall.py | 2 +- 8 files changed, 869 insertions(+), 855 deletions(-) rename addons/purchase/models/{purchase.py => purchase_order.py} (60%) create mode 100644 addons/purchase/models/purchase_order_line.py create mode 100644 addons/purchase_stock/models/purchase_order.py rename addons/purchase_stock/models/{purchase.py => purchase_order_line.py} (58%) diff --git a/addons/l10n_in_purchase/models/purchase_order.py b/addons/l10n_in_purchase/models/purchase_order.py index f1cb38979ba..f84c4e1c2cb 100644 --- a/addons/l10n_in_purchase/models/purchase_order.py +++ b/addons/l10n_in_purchase/models/purchase_order.py @@ -2,7 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from odoo.addons.purchase.models.purchase import PurchaseOrder as Purchase class PurchaseOrder(models.Model): diff --git a/addons/purchase/models/__init__.py b/addons/purchase/models/__init__.py index 11d7cea0d23..d4b546c569a 100644 --- a/addons/purchase/models/__init__.py +++ b/addons/purchase/models/__init__.py @@ -2,10 +2,11 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import account_invoice +from . import account_tax from . import analytic_account from . import analytic_applicability -from . import account_tax -from . import purchase +from . import purchase_order +from . import purchase_order_line from . import product from . import res_company from . import res_config_settings diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase_order.py similarity index 60% rename from addons/purchase/models/purchase.py rename to addons/purchase/models/purchase_order.py index 96408db0b51..02fb8b70ed3 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase_order.py @@ -1,16 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import datetime, time +from datetime import datetime from dateutil.relativedelta import relativedelta from markupsafe import escape, Markup -from pytz import timezone, UTC from werkzeug.urls import url_encode from odoo import api, fields, models, _ -from odoo.osv import expression -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, format_amount, format_date, formatLang, get_lang, groupby -from odoo.tools.float_utils import float_compare, float_is_zero, float_round +from odoo.tools import format_amount, format_date, formatLang, groupby +from odoo.tools.float_utils import float_is_zero from odoo.exceptions import UserError, ValidationError @@ -909,566 +907,3 @@ class PurchaseOrder(models.Model): if 'reminder_date_before_receipt' in vals: partner_values['reminder_date_before_receipt'] = vals.pop('reminder_date_before_receipt') return vals, partner_values - - -class PurchaseOrderLine(models.Model): - _name = 'purchase.order.line' - _inherit = 'analytic.mixin' - _description = 'Purchase Order Line' - _order = 'order_id, sequence, id' - - name = fields.Text( - string='Description', required=True, compute='_compute_price_unit_and_date_planned_and_name', store=True, readonly=False) - sequence = fields.Integer(string='Sequence', default=10) - product_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, - compute='_compute_product_qty', store=True, readonly=False) - product_uom_qty = fields.Float(string='Total Quantity', compute='_compute_product_uom_qty', store=True) - date_planned = fields.Datetime( - string='Expected Arrival', index=True, - compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True, - help="Delivery date expected from vendor. This date respectively defaults to vendor pricelist lead time then today's date.") - discount = fields.Float( - string="Discount (%)", - compute='_compute_price_unit_and_date_planned_and_name', - digits='Discount', - store=True, readonly=False) - taxes_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) - product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]") - product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') - product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], change_default=True, index='btree_not_null') - product_type = fields.Selection(related='product_id.detailed_type', readonly=True) - price_unit = fields.Float( - string='Unit Price', required=True, digits='Product Price', - compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True) - - price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', store=True) - price_total = fields.Monetary(compute='_compute_amount', string='Total', store=True) - price_tax = fields.Float(compute='_compute_amount', string='Tax', store=True) - - order_id = fields.Many2one('purchase.order', string='Order Reference', index=True, required=True, ondelete='cascade') - - company_id = fields.Many2one('res.company', related='order_id.company_id', string='Company', store=True, readonly=True) - state = fields.Selection(related='order_id.state', store=True) - - invoice_lines = fields.One2many('account.move.line', 'purchase_line_id', string="Bill Lines", readonly=True, copy=False) - - # Replace by invoiced Qty - qty_invoiced = fields.Float(compute='_compute_qty_invoiced', string="Billed Qty", digits='Product Unit of Measure', store=True) - - qty_received_method = fields.Selection([('manual', 'Manual')], string="Received Qty Method", compute='_compute_qty_received_method', store=True, - help="According to product configuration, the received quantity can be automatically computed by mechanism:\n" - " - Manual: the quantity is set manually on the line\n" - " - Stock Moves: the quantity comes from confirmed pickings\n") - qty_received = fields.Float("Received Qty", compute='_compute_qty_received', inverse='_inverse_qty_received', compute_sudo=True, store=True, digits='Product Unit of Measure') - qty_received_manual = fields.Float("Manual Received Qty", digits='Product Unit of Measure', copy=False) - qty_to_invoice = fields.Float(compute='_compute_qty_invoiced', string='To Invoice Quantity', store=True, readonly=True, - digits='Product Unit of Measure') - - partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True) - currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True) - date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True) - date_approve = fields.Datetime(related="order_id.date_approve", string='Confirmation Date', readonly=True) - product_packaging_id = fields.Many2one('product.packaging', string='Packaging', domain="[('purchase', '=', True), ('product_id', '=', product_id)]", check_company=True, - compute="_compute_product_packaging_id", store=True, readonly=False) - product_packaging_qty = fields.Float('Packaging Quantity', compute="_compute_product_packaging_qty", store=True, readonly=False) - tax_calculation_rounding_method = fields.Selection( - related='company_id.tax_calculation_rounding_method', - string='Tax calculation rounding method', readonly=True) - display_type = fields.Selection([ - ('line_section', "Section"), - ('line_note', "Note")], default=False, help="Technical field for UX purpose.") - - _sql_constraints = [ - ('accountable_required_fields', - "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL AND date_planned IS NOT NULL))", - "Missing required fields on accountable purchase order line."), - ('non_accountable_null_fields', - "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND date_planned is NULL))", - "Forbidden values on non-accountable purchase order line"), - ] - - @api.depends('product_qty', 'price_unit', 'taxes_id', 'discount') - def _compute_amount(self): - for line in self: - tax_results = self.env['account.tax']._compute_taxes([line._convert_to_tax_base_line_dict()]) - totals = list(tax_results['totals'].values())[0] - amount_untaxed = totals['amount_untaxed'] - amount_tax = totals['amount_tax'] - - line.update({ - 'price_subtotal': amount_untaxed, - 'price_tax': amount_tax, - 'price_total': amount_untaxed + amount_tax, - }) - - def _convert_to_tax_base_line_dict(self): - """ Convert the current record to a dictionary in order to use the generic taxes computation method - defined on account.tax. - - :return: A python dictionary. - """ - self.ensure_one() - return self.env['account.tax']._convert_to_tax_base_line_dict( - self, - partner=self.order_id.partner_id, - currency=self.order_id.currency_id, - product=self.product_id, - taxes=self.taxes_id, - price_unit=self.price_unit, - quantity=self.product_qty, - discount=self.discount, - price_subtotal=self.price_subtotal, - ) - - def _compute_tax_id(self): - for line in self: - line = line.with_company(line.company_id) - fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id._get_fiscal_position(line.order_id.partner_id) - # filter taxes by company - taxes = line.product_id.supplier_taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(line.company_id)) - line.taxes_id = fpos.map_tax(taxes) - - @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'qty_received', 'product_uom_qty', 'order_id.state') - def _compute_qty_invoiced(self): - for line in self: - # compute qty_invoiced - qty = 0.0 - for inv_line in line._get_invoice_lines(): - if inv_line.move_id.state not in ['cancel'] or inv_line.move_id.payment_state == 'invoicing_legacy': - if inv_line.move_id.move_type == 'in_invoice': - qty += inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom) - elif inv_line.move_id.move_type == 'in_refund': - qty -= inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom) - line.qty_invoiced = qty - - # compute qty_to_invoice - if line.order_id.state in ['purchase', 'done']: - if line.product_id.purchase_method == 'purchase': - line.qty_to_invoice = line.product_qty - line.qty_invoiced - else: - line.qty_to_invoice = line.qty_received - line.qty_invoiced - else: - line.qty_to_invoice = 0 - - def _get_invoice_lines(self): - self.ensure_one() - if self._context.get('accrual_entry_date'): - return self.invoice_lines.filtered( - lambda l: l.move_id.invoice_date and l.move_id.invoice_date <= self._context['accrual_entry_date'] - ) - else: - return self.invoice_lines - - @api.depends('product_id', 'product_id.type') - def _compute_qty_received_method(self): - for line in self: - if line.product_id and line.product_id.type in ['consu', 'service']: - line.qty_received_method = 'manual' - else: - line.qty_received_method = False - - @api.depends('qty_received_method', 'qty_received_manual') - def _compute_qty_received(self): - for line in self: - if line.qty_received_method == 'manual': - line.qty_received = line.qty_received_manual or 0.0 - else: - line.qty_received = 0.0 - - @api.onchange('qty_received') - def _inverse_qty_received(self): - """ When writing on qty_received, if the value should be modify manually (`qty_received_method` = 'manual' only), - then we put the value in `qty_received_manual`. Otherwise, `qty_received_manual` should be False since the - received qty is automatically compute by other mecanisms. - """ - for line in self: - if line.qty_received_method == 'manual': - line.qty_received_manual = line.qty_received - else: - line.qty_received_manual = 0.0 - - @api.model_create_multi - def create(self, vals_list): - for values in vals_list: - if values.get('display_type', self.default_get(['display_type'])['display_type']): - values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, date_planned=False) - else: - values.update(self._prepare_add_missing_fields(values)) - - lines = super().create(vals_list) - for line in lines: - if line.product_id and line.order_id.state == 'purchase': - msg = _("Extra line with %s ", line.product_id.display_name) - line.order_id.message_post(body=msg) - return lines - - 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 purchase order line. Instead you should delete the current line and create a new line of the proper type.")) - - if 'product_qty' in values: - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - for line in self: - if ( - line.order_id.state == "purchase" - and float_compare(line.product_qty, values["product_qty"], precision_digits=precision) != 0 - ): - line.order_id.message_post_with_source( - 'purchase.track_po_line_template', - render_values={'line': line, 'product_qty': values['product_qty']}, - subtype_xmlid='mail.mt_note', - ) - - if 'qty_received' in values: - for line in self: - line._track_qty_received(values['qty_received']) - return super(PurchaseOrderLine, self).write(values) - - @api.ondelete(at_uninstall=False) - def _unlink_except_purchase_or_done(self): - for line in self: - if line.order_id.state in ['purchase', 'done']: - state_description = {state_desc[0]: state_desc[1] for state_desc in self._fields['state']._description_selection(self.env)} - raise UserError(_('Cannot delete a purchase order line which is in state %r.', state_description.get(line.state))) - - @api.model - def _get_date_planned(self, seller, po=False): - """Return the datetime value to use as Schedule Date (``date_planned``) for - PO Lines that correspond to the given product.seller_ids, - when ordered at `date_order_str`. - - :param Model seller: used to fetch the delivery delay (if no seller - is provided, the delay is 0) - :param Model po: purchase.order, necessary only if the PO line is - not yet attached to a PO. - :rtype: datetime - :return: desired Schedule Date for the PO line - """ - date_order = po.date_order if po else self.order_id.date_order - if date_order: - return date_order + relativedelta(days=seller.delay if seller else 0) - else: - return datetime.today() + relativedelta(days=seller.delay if seller else 0) - - @api.depends('product_id', 'order_id.partner_id') - def _compute_analytic_distribution(self): - for line in self: - if not line.display_type: - distribution = self.env['account.analytic.distribution.model']._get_distribution({ - "product_id": line.product_id.id, - "product_categ_id": line.product_id.categ_id.id, - "partner_id": line.order_id.partner_id.id, - "partner_category_id": line.order_id.partner_id.category_id.ids, - "company_id": line.company_id.id, - }) - line.analytic_distribution = distribution or line.analytic_distribution - - @api.onchange('product_id') - def onchange_product_id(self): - # TODO: Remove when onchanges are replaced with computes - if not self.product_id or (self.env.context.get('origin_po_id') and self.product_qty): - return - - # Reset date, price and quantity since _onchange_quantity will provide default values - self.price_unit = self.product_qty = 0.0 - - self._product_id_change() - - self._suggest_quantity() - - def _product_id_change(self): - if not self.product_id: - return - - self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id - product_lang = self.product_id.with_context( - lang=get_lang(self.env, self.partner_id.lang).code, - partner_id=self.partner_id.id, - company_id=self.company_id.id, - ) - self.name = self._get_product_purchase_description(product_lang) - - self._compute_tax_id() - - @api.onchange('product_id') - def onchange_product_id_warning(self): - if not self.product_id or not self.env.user.has_group('purchase.group_warning_purchase'): - return - warning = {} - title = False - message = False - - product_info = self.product_id - - if product_info.purchase_line_warn != 'no-message': - title = _("Warning for %s", product_info.name) - message = product_info.purchase_line_warn_msg - warning['title'] = title - warning['message'] = message - if product_info.purchase_line_warn == 'block': - self.product_id = False - return {'warning': warning} - return {} - - @api.depends('product_qty', 'product_uom', 'company_id') - def _compute_price_unit_and_date_planned_and_name(self): - for line in self: - if not line.product_id or line.invoice_lines or not line.company_id: - continue - params = {'order_id': line.order_id} - seller = line.product_id._select_seller( - partner_id=line.partner_id, - quantity=line.product_qty, - date=line.order_id.date_order and line.order_id.date_order.date() or fields.Date.context_today(line), - uom_id=line.product_uom, - params=params) - - if seller or not line.date_planned: - line.date_planned = line._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT) - - # If not seller, use the standard price. It needs a proper currency conversion. - if not seller: - unavailable_seller = line.product_id.seller_ids.filtered( - lambda s: s.partner_id == line.order_id.partner_id) - if not unavailable_seller and line.price_unit and line.product_uom == line._origin.product_uom: - # Avoid to modify the price unit if there is no price list for this partner and - # the line has already one to avoid to override unit price set manually. - continue - po_line_uom = line.product_uom or line.product_id.uom_po_id - price_unit = line.env['account.tax']._fix_tax_included_price_company( - line.product_id.uom_id._compute_price(line.product_id.standard_price, po_line_uom), - line.product_id.supplier_taxes_id, - line.taxes_id, - line.company_id, - ) - price_unit = line.product_id.cost_currency_id._convert( - price_unit, - line.currency_id, - line.company_id, - line.date_order or fields.Date.context_today(line), - False - ) - line.price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price'))) - continue - - price_unit = line.env['account.tax']._fix_tax_included_price_company(seller.price, line.product_id.supplier_taxes_id, line.taxes_id, line.company_id) if seller else 0.0 - price_unit = seller.currency_id._convert(price_unit, line.currency_id, line.company_id, line.date_order or fields.Date.context_today(line), False) - price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price'))) - line.price_unit = seller.product_uom._compute_price(price_unit, line.product_uom) - line.discount = seller.discount or 0.0 - - # record product names to avoid resetting custom descriptions - default_names = [] - vendors = line.product_id._prepare_sellers({}) - for vendor in vendors: - product_ctx = {'seller_id': vendor.id, 'lang': get_lang(line.env, line.partner_id.lang).code} - default_names.append(line._get_product_purchase_description(line.product_id.with_context(product_ctx))) - if not line.name or line.name in default_names: - product_ctx = {'seller_id': seller.id, 'lang': get_lang(line.env, line.partner_id.lang).code} - line.name = line._get_product_purchase_description(line.product_id.with_context(product_ctx)) - - @api.depends('product_id', 'product_qty', 'product_uom') - def _compute_product_packaging_id(self): - for line in self: - # remove packaging if not match the product - if line.product_packaging_id.product_id != line.product_id: - line.product_packaging_id = False - # suggest biggest suitable packaging - if line.product_id and line.product_qty and line.product_uom: - line.product_packaging_id = line.product_id.packaging_ids.filtered('purchase').with_context(myCompany=line.company_id)._find_suitable_product_packaging(line.product_qty, line.product_uom) or line.product_packaging_id - - @api.onchange('product_packaging_id') - def _onchange_product_packaging_id(self): - if self.product_packaging_id and self.product_qty: - newqty = self.product_packaging_id._check_qty(self.product_qty, self.product_uom, "UP") - if float_compare(newqty, self.product_qty, precision_rounding=self.product_uom.rounding) != 0: - return { - 'warning': { - 'title': _('Warning'), - 'message': _( - "This product is packaged by %(pack_size).2f %(pack_name)s. You should purchase %(quantity).2f %(unit)s.", - pack_size=self.product_packaging_id.qty, - pack_name=self.product_id.uom_id.name, - quantity=newqty, - unit=self.product_uom.name - ), - }, - } - - @api.depends('product_packaging_id', 'product_uom', 'product_qty') - def _compute_product_packaging_qty(self): - self.product_packaging_qty = 0 - for line in self: - if not line.product_packaging_id: - continue - line.product_packaging_qty = line.product_packaging_id._compute_qty(line.product_qty, line.product_uom) - - @api.depends('product_packaging_qty') - def _compute_product_qty(self): - for line in self: - if line.product_packaging_id: - packaging_uom = line.product_packaging_id.product_uom_id - qty_per_packaging = line.product_packaging_id.qty - product_qty = packaging_uom._compute_quantity(line.product_packaging_qty * qty_per_packaging, line.product_uom) - if float_compare(product_qty, line.product_qty, precision_rounding=line.product_uom.rounding) != 0: - line.product_qty = product_qty - - @api.depends('product_uom', 'product_qty', 'product_id.uom_id') - def _compute_product_uom_qty(self): - for line in self: - if line.product_id and line.product_id.uom_id != line.product_uom: - line.product_uom_qty = line.product_uom._compute_quantity(line.product_qty, line.product_id.uom_id) - else: - line.product_uom_qty = line.product_qty - - def _get_gross_price_unit(self): - self.ensure_one() - price_unit = self.price_unit - if self.discount: - price_unit = price_unit * (1 - self.discount / 100) - if self.taxes_id: - qty = self.product_qty or 1 - price_unit_prec = self.env['decimal.precision'].precision_get('Product Price') - price_unit = self.taxes_id.with_context(round=False).compute_all(price_unit, currency=self.order_id.currency_id, quantity=qty)['total_void'] - price_unit = float_round(price_unit / qty, precision_digits=price_unit_prec) - if self.product_uom.id != self.product_id.uom_id.id: - price_unit *= self.product_uom.factor / self.product_id.uom_id.factor - return price_unit - - def action_purchase_history(self): - self.ensure_one() - action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history") - action['domain'] = [('state', 'in', ['purchase', 'done']), ('product_id', '=', self.product_id.id)] - action['display_name'] = _("Purchase History for %s", self.product_id.display_name) - action['context'] = { - 'search_default_partner_id': self.partner_id.id - } - - return action - - def _suggest_quantity(self): - ''' - Suggest a minimal quantity based on the seller - ''' - if not self.product_id: - return - seller_min_qty = self.product_id.seller_ids\ - .filtered(lambda r: r.partner_id == self.order_id.partner_id and (not r.product_id or r.product_id == self.product_id))\ - .sorted(key=lambda r: r.min_qty) - if seller_min_qty: - self.product_qty = seller_min_qty[0].min_qty or 1.0 - self.product_uom = seller_min_qty[0].product_uom - else: - self.product_qty = 1.0 - - def _get_product_purchase_description(self, product_lang): - self.ensure_one() - name = product_lang.display_name - if product_lang.description_purchase: - name += '\n' + product_lang.description_purchase - - return name - - def _prepare_account_move_line(self, move=False): - self.ensure_one() - aml_currency = move and move.currency_id or self.currency_id - date = move and move.date or fields.Date.today() - res = { - 'display_type': self.display_type or 'product', - 'name': '%s: %s' % (self.order_id.name, self.name), - 'product_id': self.product_id.id, - 'product_uom_id': self.product_uom.id, - 'quantity': self.qty_to_invoice, - 'discount': self.discount, - 'price_unit': self.currency_id._convert(self.price_unit, aml_currency, self.company_id, date, round=False), - 'tax_ids': [(6, 0, self.taxes_id.ids)], - 'purchase_line_id': self.id, - } - if self.analytic_distribution and not self.display_type: - res['analytic_distribution'] = self.analytic_distribution - return res - - @api.model - def _prepare_add_missing_fields(self, values): - """ Deduce missing required fields from the onchange """ - res = {} - onchange_fields = ['name', 'price_unit', 'product_qty', 'product_uom', 'taxes_id', 'date_planned'] - if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields): - line = self.new(values) - line.onchange_product_id() - for field in onchange_fields: - if field not in values: - res[field] = line._fields[field].convert_to_write(line[field], line) - return res - - @api.model - def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, supplier, po): - partner = supplier.partner_id - uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id, rounding_method='HALF-UP') - # _select_seller is used if the supplier have different price depending - # the quantities ordered. - seller = product_id.with_company(company_id)._select_seller( - partner_id=partner, - quantity=uom_po_qty, - date=max(po.date_order and po.date_order.date(), fields.Date.today()), - uom_id=product_id.uom_po_id) - - product_taxes = product_id.supplier_taxes_id.filtered(lambda x: x.company_id.id == company_id.id) - taxes = po.fiscal_position_id.map_tax(product_taxes) - - price_unit = self.env['account.tax']._fix_tax_included_price_company( - seller.price, product_taxes, taxes, company_id) if seller else 0.0 - if price_unit and seller and po.currency_id and seller.currency_id != po.currency_id: - price_unit = seller.currency_id._convert( - price_unit, po.currency_id, po.company_id, po.date_order or fields.Date.today()) - - product_lang = product_id.with_prefetch().with_context( - lang=partner.lang, - partner_id=partner.id, - ) - name = product_lang.with_context(seller_id=seller.id).display_name - if product_lang.description_purchase: - name += '\n' + product_lang.description_purchase - - date_planned = self.order_id.date_planned or self._get_date_planned(seller, po=po) - - return { - 'name': name, - 'product_qty': uom_po_qty, - 'product_id': product_id.id, - 'product_uom': product_id.uom_po_id.id, - 'price_unit': price_unit, - 'date_planned': date_planned, - 'taxes_id': [(6, 0, taxes.ids)], - 'order_id': po.id, - } - - def _convert_to_middle_of_day(self, date): - """Return a datetime which is the noon of the input date(time) according - to order user's time zone, convert to UTC time. - """ - return timezone(self.order_id.user_id.tz or self.company_id.partner_id.tz or 'UTC').localize(datetime.combine(date, time(12))).astimezone(UTC).replace(tzinfo=None) - - def _update_date_planned(self, updated_date): - self.date_planned = updated_date - - def _track_qty_received(self, new_qty): - self.ensure_one() - # don't track anything when coming from the accrued expense entry wizard, as it is only computing fields at a past date to get relevant amounts - # and doesn't actually change anything to the current record - if self.env.context.get('accrual_entry_date'): - return - if new_qty != self.qty_received and self.order_id.state == 'purchase': - self.order_id.message_post_with_source( - 'purchase.track_po_line_qty_received_template', - render_values={'line': self, 'qty_received': new_qty}, - subtype_xmlid='mail.mt_note', - ) - - def _validate_analytic_distribution(self): - for line in self.filtered(lambda l: not l.display_type): - line._validate_distribution(**{ - 'product': line.product_id.id, - 'business_domain': 'purchase_order', - 'company_id': line.company_id.id, - }) diff --git a/addons/purchase/models/purchase_order_line.py b/addons/purchase/models/purchase_order_line.py new file mode 100644 index 00000000000..0360c0cb173 --- /dev/null +++ b/addons/purchase/models/purchase_order_line.py @@ -0,0 +1,572 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime, time +from dateutil.relativedelta import relativedelta +from pytz import timezone, UTC + +from odoo import api, fields, models, _ +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, get_lang +from odoo.tools.float_utils import float_compare, float_round +from odoo.exceptions import UserError + + +class PurchaseOrderLine(models.Model): + _name = 'purchase.order.line' + _inherit = 'analytic.mixin' + _description = 'Purchase Order Line' + _order = 'order_id, sequence, id' + + name = fields.Text( + string='Description', required=True, compute='_compute_price_unit_and_date_planned_and_name', store=True, readonly=False) + sequence = fields.Integer(string='Sequence', default=10) + product_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, + compute='_compute_product_qty', store=True, readonly=False) + product_uom_qty = fields.Float(string='Total Quantity', compute='_compute_product_uom_qty', store=True) + date_planned = fields.Datetime( + string='Expected Arrival', index=True, + compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True, + help="Delivery date expected from vendor. This date respectively defaults to vendor pricelist lead time then today's date.") + discount = fields.Float( + string="Discount (%)", + compute='_compute_price_unit_and_date_planned_and_name', + digits='Discount', + store=True, readonly=False) + taxes_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) + product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') + product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], change_default=True, index='btree_not_null') + product_type = fields.Selection(related='product_id.detailed_type', readonly=True) + price_unit = fields.Float( + string='Unit Price', required=True, digits='Product Price', + compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True) + + price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', store=True) + price_total = fields.Monetary(compute='_compute_amount', string='Total', store=True) + price_tax = fields.Float(compute='_compute_amount', string='Tax', store=True) + + order_id = fields.Many2one('purchase.order', string='Order Reference', index=True, required=True, ondelete='cascade') + + company_id = fields.Many2one('res.company', related='order_id.company_id', string='Company', store=True, readonly=True) + state = fields.Selection(related='order_id.state', store=True) + + invoice_lines = fields.One2many('account.move.line', 'purchase_line_id', string="Bill Lines", readonly=True, copy=False) + + # Replace by invoiced Qty + qty_invoiced = fields.Float(compute='_compute_qty_invoiced', string="Billed Qty", digits='Product Unit of Measure', store=True) + + qty_received_method = fields.Selection([('manual', 'Manual')], string="Received Qty Method", compute='_compute_qty_received_method', store=True, + help="According to product configuration, the received quantity can be automatically computed by mechanism:\n" + " - Manual: the quantity is set manually on the line\n" + " - Stock Moves: the quantity comes from confirmed pickings\n") + qty_received = fields.Float("Received Qty", compute='_compute_qty_received', inverse='_inverse_qty_received', compute_sudo=True, store=True, digits='Product Unit of Measure') + qty_received_manual = fields.Float("Manual Received Qty", digits='Product Unit of Measure', copy=False) + qty_to_invoice = fields.Float(compute='_compute_qty_invoiced', string='To Invoice Quantity', store=True, readonly=True, + digits='Product Unit of Measure') + + partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True) + currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True) + date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True) + date_approve = fields.Datetime(related="order_id.date_approve", string='Confirmation Date', readonly=True) + product_packaging_id = fields.Many2one('product.packaging', string='Packaging', domain="[('purchase', '=', True), ('product_id', '=', product_id)]", check_company=True, + compute="_compute_product_packaging_id", store=True, readonly=False) + product_packaging_qty = fields.Float('Packaging Quantity', compute="_compute_product_packaging_qty", store=True, readonly=False) + tax_calculation_rounding_method = fields.Selection( + related='company_id.tax_calculation_rounding_method', + string='Tax calculation rounding method', readonly=True) + display_type = fields.Selection([ + ('line_section', "Section"), + ('line_note', "Note")], default=False, help="Technical field for UX purpose.") + + _sql_constraints = [ + ('accountable_required_fields', + "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL AND date_planned IS NOT NULL))", + "Missing required fields on accountable purchase order line."), + ('non_accountable_null_fields', + "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND date_planned is NULL))", + "Forbidden values on non-accountable purchase order line"), + ] + + @api.depends('product_qty', 'price_unit', 'taxes_id', 'discount') + def _compute_amount(self): + for line in self: + tax_results = self.env['account.tax']._compute_taxes([line._convert_to_tax_base_line_dict()]) + totals = list(tax_results['totals'].values())[0] + amount_untaxed = totals['amount_untaxed'] + amount_tax = totals['amount_tax'] + + line.update({ + 'price_subtotal': amount_untaxed, + 'price_tax': amount_tax, + 'price_total': amount_untaxed + amount_tax, + }) + + def _convert_to_tax_base_line_dict(self): + """ Convert the current record to a dictionary in order to use the generic taxes computation method + defined on account.tax. + + :return: A python dictionary. + """ + self.ensure_one() + return self.env['account.tax']._convert_to_tax_base_line_dict( + self, + partner=self.order_id.partner_id, + currency=self.order_id.currency_id, + product=self.product_id, + taxes=self.taxes_id, + price_unit=self.price_unit, + quantity=self.product_qty, + discount=self.discount, + price_subtotal=self.price_subtotal, + ) + + def _compute_tax_id(self): + for line in self: + line = line.with_company(line.company_id) + fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id._get_fiscal_position(line.order_id.partner_id) + # filter taxes by company + taxes = line.product_id.supplier_taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(line.company_id)) + line.taxes_id = fpos.map_tax(taxes) + + @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'qty_received', 'product_uom_qty', 'order_id.state') + def _compute_qty_invoiced(self): + for line in self: + # compute qty_invoiced + qty = 0.0 + for inv_line in line._get_invoice_lines(): + if inv_line.move_id.state not in ['cancel'] or inv_line.move_id.payment_state == 'invoicing_legacy': + if inv_line.move_id.move_type == 'in_invoice': + qty += inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom) + elif inv_line.move_id.move_type == 'in_refund': + qty -= inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom) + line.qty_invoiced = qty + + # compute qty_to_invoice + if line.order_id.state in ['purchase', 'done']: + if line.product_id.purchase_method == 'purchase': + line.qty_to_invoice = line.product_qty - line.qty_invoiced + else: + line.qty_to_invoice = line.qty_received - line.qty_invoiced + else: + line.qty_to_invoice = 0 + + def _get_invoice_lines(self): + self.ensure_one() + if self._context.get('accrual_entry_date'): + return self.invoice_lines.filtered( + lambda l: l.move_id.invoice_date and l.move_id.invoice_date <= self._context['accrual_entry_date'] + ) + else: + return self.invoice_lines + + @api.depends('product_id', 'product_id.type') + def _compute_qty_received_method(self): + for line in self: + if line.product_id and line.product_id.type in ['consu', 'service']: + line.qty_received_method = 'manual' + else: + line.qty_received_method = False + + @api.depends('qty_received_method', 'qty_received_manual') + def _compute_qty_received(self): + for line in self: + if line.qty_received_method == 'manual': + line.qty_received = line.qty_received_manual or 0.0 + else: + line.qty_received = 0.0 + + @api.onchange('qty_received') + def _inverse_qty_received(self): + """ When writing on qty_received, if the value should be modify manually (`qty_received_method` = 'manual' only), + then we put the value in `qty_received_manual`. Otherwise, `qty_received_manual` should be False since the + received qty is automatically compute by other mecanisms. + """ + for line in self: + if line.qty_received_method == 'manual': + line.qty_received_manual = line.qty_received + else: + line.qty_received_manual = 0.0 + + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + if values.get('display_type', self.default_get(['display_type'])['display_type']): + values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, date_planned=False) + else: + values.update(self._prepare_add_missing_fields(values)) + + lines = super().create(vals_list) + for line in lines: + if line.product_id and line.order_id.state == 'purchase': + msg = _("Extra line with %s ", line.product_id.display_name) + line.order_id.message_post(body=msg) + return lines + + 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 purchase order line. Instead you should delete the current line and create a new line of the proper type.")) + + if 'product_qty' in values: + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + for line in self: + if ( + line.order_id.state == "purchase" + and float_compare(line.product_qty, values["product_qty"], precision_digits=precision) != 0 + ): + line.order_id.message_post_with_source( + 'purchase.track_po_line_template', + render_values={'line': line, 'product_qty': values['product_qty']}, + subtype_xmlid='mail.mt_note', + ) + + if 'qty_received' in values: + for line in self: + line._track_qty_received(values['qty_received']) + return super(PurchaseOrderLine, self).write(values) + + @api.ondelete(at_uninstall=False) + def _unlink_except_purchase_or_done(self): + for line in self: + if line.order_id.state in ['purchase', 'done']: + state_description = {state_desc[0]: state_desc[1] for state_desc in self._fields['state']._description_selection(self.env)} + raise UserError(_('Cannot delete a purchase order line which is in state %r.', state_description.get(line.state))) + + @api.model + def _get_date_planned(self, seller, po=False): + """Return the datetime value to use as Schedule Date (``date_planned``) for + PO Lines that correspond to the given product.seller_ids, + when ordered at `date_order_str`. + + :param Model seller: used to fetch the delivery delay (if no seller + is provided, the delay is 0) + :param Model po: purchase.order, necessary only if the PO line is + not yet attached to a PO. + :rtype: datetime + :return: desired Schedule Date for the PO line + """ + date_order = po.date_order if po else self.order_id.date_order + if date_order: + return date_order + relativedelta(days=seller.delay if seller else 0) + else: + return datetime.today() + relativedelta(days=seller.delay if seller else 0) + + @api.depends('product_id', 'order_id.partner_id') + def _compute_analytic_distribution(self): + for line in self: + if not line.display_type: + distribution = self.env['account.analytic.distribution.model']._get_distribution({ + "product_id": line.product_id.id, + "product_categ_id": line.product_id.categ_id.id, + "partner_id": line.order_id.partner_id.id, + "partner_category_id": line.order_id.partner_id.category_id.ids, + "company_id": line.company_id.id, + }) + line.analytic_distribution = distribution or line.analytic_distribution + + @api.onchange('product_id') + def onchange_product_id(self): + # TODO: Remove when onchanges are replaced with computes + if not self.product_id or (self.env.context.get('origin_po_id') and self.product_qty): + return + + # Reset date, price and quantity since _onchange_quantity will provide default values + self.price_unit = self.product_qty = 0.0 + + self._product_id_change() + + self._suggest_quantity() + + def _product_id_change(self): + if not self.product_id: + return + + self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id + product_lang = self.product_id.with_context( + lang=get_lang(self.env, self.partner_id.lang).code, + partner_id=self.partner_id.id, + company_id=self.company_id.id, + ) + self.name = self._get_product_purchase_description(product_lang) + + self._compute_tax_id() + + @api.onchange('product_id') + def onchange_product_id_warning(self): + if not self.product_id or not self.env.user.has_group('purchase.group_warning_purchase'): + return + warning = {} + title = False + message = False + + product_info = self.product_id + + if product_info.purchase_line_warn != 'no-message': + title = _("Warning for %s", product_info.name) + message = product_info.purchase_line_warn_msg + warning['title'] = title + warning['message'] = message + if product_info.purchase_line_warn == 'block': + self.product_id = False + return {'warning': warning} + return {} + + @api.depends('product_qty', 'product_uom', 'company_id') + def _compute_price_unit_and_date_planned_and_name(self): + for line in self: + if not line.product_id or line.invoice_lines or not line.company_id: + continue + params = {'order_id': line.order_id} + seller = line.product_id._select_seller( + partner_id=line.partner_id, + quantity=line.product_qty, + date=line.order_id.date_order and line.order_id.date_order.date() or fields.Date.context_today(line), + uom_id=line.product_uom, + params=params) + + if seller or not line.date_planned: + line.date_planned = line._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT) + + # If not seller, use the standard price. It needs a proper currency conversion. + if not seller: + unavailable_seller = line.product_id.seller_ids.filtered( + lambda s: s.partner_id == line.order_id.partner_id) + if not unavailable_seller and line.price_unit and line.product_uom == line._origin.product_uom: + # Avoid to modify the price unit if there is no price list for this partner and + # the line has already one to avoid to override unit price set manually. + continue + po_line_uom = line.product_uom or line.product_id.uom_po_id + price_unit = line.env['account.tax']._fix_tax_included_price_company( + line.product_id.uom_id._compute_price(line.product_id.standard_price, po_line_uom), + line.product_id.supplier_taxes_id, + line.taxes_id, + line.company_id, + ) + price_unit = line.product_id.cost_currency_id._convert( + price_unit, + line.currency_id, + line.company_id, + line.date_order or fields.Date.context_today(line), + False + ) + line.price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price'))) + continue + + price_unit = line.env['account.tax']._fix_tax_included_price_company(seller.price, line.product_id.supplier_taxes_id, line.taxes_id, line.company_id) if seller else 0.0 + price_unit = seller.currency_id._convert(price_unit, line.currency_id, line.company_id, line.date_order or fields.Date.context_today(line), False) + price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price'))) + line.price_unit = seller.product_uom._compute_price(price_unit, line.product_uom) + line.discount = seller.discount or 0.0 + + # record product names to avoid resetting custom descriptions + default_names = [] + vendors = line.product_id._prepare_sellers({}) + for vendor in vendors: + product_ctx = {'seller_id': vendor.id, 'lang': get_lang(line.env, line.partner_id.lang).code} + default_names.append(line._get_product_purchase_description(line.product_id.with_context(product_ctx))) + if not line.name or line.name in default_names: + product_ctx = {'seller_id': seller.id, 'lang': get_lang(line.env, line.partner_id.lang).code} + line.name = line._get_product_purchase_description(line.product_id.with_context(product_ctx)) + + @api.depends('product_id', 'product_qty', 'product_uom') + def _compute_product_packaging_id(self): + for line in self: + # remove packaging if not match the product + if line.product_packaging_id.product_id != line.product_id: + line.product_packaging_id = False + # suggest biggest suitable packaging + if line.product_id and line.product_qty and line.product_uom: + line.product_packaging_id = line.product_id.packaging_ids.filtered('purchase')._find_suitable_product_packaging(line.product_qty, line.product_uom) or line.product_packaging_id + + @api.onchange('product_packaging_id') + def _onchange_product_packaging_id(self): + if self.product_packaging_id and self.product_qty: + newqty = self.product_packaging_id._check_qty(self.product_qty, self.product_uom, "UP") + if float_compare(newqty, self.product_qty, precision_rounding=self.product_uom.rounding) != 0: + return { + 'warning': { + 'title': _('Warning'), + 'message': _( + "This product is packaged by %(pack_size).2f %(pack_name)s. You should purchase %(quantity).2f %(unit)s.", + pack_size=self.product_packaging_id.qty, + pack_name=self.product_id.uom_id.name, + quantity=newqty, + unit=self.product_uom.name + ), + }, + } + + @api.depends('product_packaging_id', 'product_uom', 'product_qty') + def _compute_product_packaging_qty(self): + self.product_packaging_qty = 0 + for line in self: + if not line.product_packaging_id: + continue + line.product_packaging_qty = line.product_packaging_id._compute_qty(line.product_qty, line.product_uom) + + @api.depends('product_packaging_qty') + def _compute_product_qty(self): + for line in self: + if line.product_packaging_id: + packaging_uom = line.product_packaging_id.product_uom_id + qty_per_packaging = line.product_packaging_id.qty + product_qty = packaging_uom._compute_quantity(line.product_packaging_qty * qty_per_packaging, line.product_uom) + if float_compare(product_qty, line.product_qty, precision_rounding=line.product_uom.rounding) != 0: + line.product_qty = product_qty + + @api.depends('product_uom', 'product_qty', 'product_id.uom_id') + def _compute_product_uom_qty(self): + for line in self: + if line.product_id and line.product_id.uom_id != line.product_uom: + line.product_uom_qty = line.product_uom._compute_quantity(line.product_qty, line.product_id.uom_id) + else: + line.product_uom_qty = line.product_qty + + def _get_gross_price_unit(self): + self.ensure_one() + price_unit = self.price_unit + if self.discount: + price_unit = price_unit * (1 - self.discount / 100) + if self.taxes_id: + qty = self.product_qty or 1 + price_unit_prec = self.env['decimal.precision'].precision_get('Product Price') + price_unit = self.taxes_id.with_context(round=False).compute_all(price_unit, currency=self.order_id.currency_id, quantity=qty)['total_void'] + price_unit = float_round(price_unit / qty, precision_digits=price_unit_prec) + if self.product_uom.id != self.product_id.uom_id.id: + price_unit *= self.product_uom.factor / self.product_id.uom_id.factor + return price_unit + + def action_purchase_history(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history") + action['domain'] = [('state', 'in', ['purchase', 'done']), ('product_id', '=', self.product_id.id)] + action['display_name'] = _("Purchase History for %s", self.product_id.display_name) + action['context'] = { + 'search_default_partner_id': self.partner_id.id + } + + return action + + def _suggest_quantity(self): + ''' + Suggest a minimal quantity based on the seller + ''' + if not self.product_id: + return + seller_min_qty = self.product_id.seller_ids\ + .filtered(lambda r: r.partner_id == self.order_id.partner_id and (not r.product_id or r.product_id == self.product_id))\ + .sorted(key=lambda r: r.min_qty) + if seller_min_qty: + self.product_qty = seller_min_qty[0].min_qty or 1.0 + self.product_uom = seller_min_qty[0].product_uom + else: + self.product_qty = 1.0 + + def _get_product_purchase_description(self, product_lang): + self.ensure_one() + name = product_lang.display_name + if product_lang.description_purchase: + name += '\n' + product_lang.description_purchase + + return name + + def _prepare_account_move_line(self, move=False): + self.ensure_one() + aml_currency = move and move.currency_id or self.currency_id + date = move and move.date or fields.Date.today() + res = { + 'display_type': self.display_type or 'product', + 'name': '%s: %s' % (self.order_id.name, self.name), + 'product_id': self.product_id.id, + 'product_uom_id': self.product_uom.id, + 'quantity': self.qty_to_invoice, + 'discount': self.discount, + 'price_unit': self.currency_id._convert(self.price_unit, aml_currency, self.company_id, date, round=False), + 'tax_ids': [(6, 0, self.taxes_id.ids)], + 'purchase_line_id': self.id, + } + if self.analytic_distribution and not self.display_type: + res['analytic_distribution'] = self.analytic_distribution + return res + + @api.model + def _prepare_add_missing_fields(self, values): + """ Deduce missing required fields from the onchange """ + res = {} + onchange_fields = ['name', 'price_unit', 'product_qty', 'product_uom', 'taxes_id', 'date_planned'] + if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields): + line = self.new(values) + line.onchange_product_id() + for field in onchange_fields: + if field not in values: + res[field] = line._fields[field].convert_to_write(line[field], line) + return res + + @api.model + def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, supplier, po): + partner = supplier.partner_id + uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id, rounding_method='HALF-UP') + # _select_seller is used if the supplier have different price depending + # the quantities ordered. + seller = product_id.with_company(company_id)._select_seller( + partner_id=partner, + quantity=uom_po_qty, + date=max(po.date_order and po.date_order.date(), fields.Date.today()), + uom_id=product_id.uom_po_id) + + product_taxes = product_id.supplier_taxes_id.filtered(lambda x: x.company_id.id == company_id.id) + taxes = po.fiscal_position_id.map_tax(product_taxes) + + price_unit = self.env['account.tax']._fix_tax_included_price_company( + seller.price, product_taxes, taxes, company_id) if seller else 0.0 + if price_unit and seller and po.currency_id and seller.currency_id != po.currency_id: + price_unit = seller.currency_id._convert( + price_unit, po.currency_id, po.company_id, po.date_order or fields.Date.today()) + + product_lang = product_id.with_prefetch().with_context( + lang=partner.lang, + partner_id=partner.id, + ) + name = product_lang.with_context(seller_id=seller.id).display_name + if product_lang.description_purchase: + name += '\n' + product_lang.description_purchase + + date_planned = self.order_id.date_planned or self._get_date_planned(seller, po=po) + + return { + 'name': name, + 'product_qty': uom_po_qty, + 'product_id': product_id.id, + 'product_uom': product_id.uom_po_id.id, + 'price_unit': price_unit, + 'date_planned': date_planned, + 'taxes_id': [(6, 0, taxes.ids)], + 'order_id': po.id, + } + + def _convert_to_middle_of_day(self, date): + """Return a datetime which is the noon of the input date(time) according + to order user's time zone, convert to UTC time. + """ + return timezone(self.order_id.user_id.tz or self.company_id.partner_id.tz or 'UTC').localize(datetime.combine(date, time(12))).astimezone(UTC).replace(tzinfo=None) + + def _update_date_planned(self, updated_date): + self.date_planned = updated_date + + def _track_qty_received(self, new_qty): + self.ensure_one() + # don't track anything when coming from the accrued expense entry wizard, as it is only computing fields at a past date to get relevant amounts + # and doesn't actually change anything to the current record + if self.env.context.get('accrual_entry_date'): + return + if new_qty != self.qty_received and self.order_id.state == 'purchase': + self.order_id.message_post_with_source( + 'purchase.track_po_line_qty_received_template', + render_values={'line': self, 'qty_received': new_qty}, + subtype_xmlid='mail.mt_note', + ) + + def _validate_analytic_distribution(self): + for line in self.filtered(lambda l: not l.display_type): + line._validate_distribution(**{ + 'product': line.product_id.id, + 'business_domain': 'purchase_order', + 'company_id': line.company_id.id, + }) diff --git a/addons/purchase_stock/models/__init__.py b/addons/purchase_stock/models/__init__.py index fc159a2d26e..5b68a04ecee 100644 --- a/addons/purchase_stock/models/__init__.py +++ b/addons/purchase_stock/models/__init__.py @@ -4,7 +4,8 @@ from . import account_invoice from . import account_move_line from . import product -from . import purchase +from . import purchase_order +from . import purchase_order_line from . import res_config_settings from . import res_partner from . import res_company diff --git a/addons/purchase_stock/models/purchase_order.py b/addons/purchase_stock/models/purchase_order.py new file mode 100644 index 00000000000..35233691782 --- /dev/null +++ b/addons/purchase_stock/models/purchase_order.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from markupsafe import Markup + +from odoo import api, Command, fields, models, SUPERUSER_ID, _ +from odoo.tools.float_utils import float_compare +from odoo.exceptions import UserError + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + @api.model + def _default_picking_type(self): + return self._get_picking_type(self.env.context.get('company_id') or self.env.company.id) + + incoterm_location = fields.Char(string='Incoterm Location') + incoming_picking_count = fields.Integer("Incoming Shipment count", compute='_compute_incoming_picking_count') + picking_ids = fields.Many2many('stock.picking', compute='_compute_picking_ids', string='Receptions', copy=False, store=True) + dest_address_id = fields.Many2one('res.partner', compute='_compute_dest_address_id', store=True, readonly=False) + picking_type_id = fields.Many2one('stock.picking.type', 'Deliver To', required=True, default=_default_picking_type, domain="['|', ('warehouse_id', '=', False), ('warehouse_id.company_id', '=', company_id)]", + help="This will determine operation type of incoming shipment") + default_location_dest_id_usage = fields.Selection(related='picking_type_id.default_location_dest_id.usage', string='Destination Location Type', + help="Technical field used to display the Drop Ship Address", readonly=True) + group_id = fields.Many2one('procurement.group', string="Procurement Group", copy=False) + is_shipped = fields.Boolean(compute="_compute_is_shipped") + effective_date = fields.Datetime("Arrival", compute='_compute_effective_date', store=True, copy=False, + help="Completion date of the first receipt order.") + on_time_rate = fields.Float(related='partner_id.on_time_rate', compute_sudo=False) + receipt_status = fields.Selection([ + ('pending', 'Not Received'), + ('partial', 'Partially Received'), + ('full', 'Fully Received'), + ], string='Receipt Status', compute='_compute_receipt_status', store=True) + + @api.depends('order_line.move_ids.picking_id') + def _compute_picking_ids(self): + for order in self: + order.picking_ids = order.order_line.move_ids.picking_id + + @api.depends('picking_ids') + def _compute_incoming_picking_count(self): + for order in self: + order.incoming_picking_count = len(order.picking_ids) + + @api.depends('picking_ids.date_done') + def _compute_effective_date(self): + for order in self: + pickings = order.picking_ids.filtered(lambda x: x.state == 'done' and x.location_dest_id.usage != 'supplier' and x.date_done) + order.effective_date = min(pickings.mapped('date_done'), default=False) + + @api.depends('picking_ids', 'picking_ids.state') + def _compute_is_shipped(self): + for order in self: + if order.picking_ids and all(x.state in ['done', 'cancel'] for x in order.picking_ids): + order.is_shipped = True + else: + order.is_shipped = False + + @api.depends('picking_ids', 'picking_ids.state') + def _compute_receipt_status(self): + for order in self: + if not order.picking_ids or all(p.state == 'cancel' for p in order.picking_ids): + order.receipt_status = False + elif all(p.state in ['done', 'cancel'] for p in order.picking_ids): + order.receipt_status = 'full' + elif any(p.state == 'done' for p in order.picking_ids): + order.receipt_status = 'partial' + else: + order.receipt_status = 'pending' + + @api.depends('picking_type_id') + def _compute_dest_address_id(self): + self.filtered(lambda po: po.picking_type_id.default_location_dest_id.usage != 'customer').dest_address_id = False + + @api.onchange('company_id') + def _onchange_company_id(self): + p_type = self.picking_type_id + if not(p_type and p_type.code == 'incoming' and (p_type.warehouse_id.company_id == self.company_id or not p_type.warehouse_id)): + self.picking_type_id = self._get_picking_type(self.company_id.id) + + # -------------------------------------------------- + # CRUD + # -------------------------------------------------- + + def write(self, vals): + if vals.get('order_line') and self.state == 'purchase': + for order in self: + pre_order_line_qty = {order_line: order_line.product_qty for order_line in order.mapped('order_line')} + res = super(PurchaseOrder, self).write(vals) + if vals.get('order_line') and self.state == 'purchase': + for order in self: + to_log = {} + for order_line in order.order_line: + if pre_order_line_qty.get(order_line, False) and float_compare(pre_order_line_qty[order_line], order_line.product_qty, precision_rounding=order_line.product_uom.rounding) > 0: + to_log[order_line] = (order_line.product_qty, pre_order_line_qty[order_line]) + if to_log: + order._log_decrease_ordered_quantity(to_log) + return res + + # -------------------------------------------------- + # Actions + # -------------------------------------------------- + + def button_approve(self, force=False): + result = super(PurchaseOrder, self).button_approve(force=force) + self._create_picking() + return result + + def button_cancel(self): + for order in self: + for move in order.order_line.mapped('move_ids'): + if move.state == 'done': + raise UserError(_('Unable to cancel purchase order %s as some receptions have already been done.', order.name)) + # If the product is MTO, change the procure_method of the closest move to purchase to MTS. + # The purpose is to link the po that the user will manually generate to the existing moves's chain. + if order.state in ('draft', 'sent', 'to approve', 'purchase'): + for order_line in order.order_line: + order_line.move_ids._action_cancel() + if order_line.move_dest_ids: + moves_to_unlink = order_line.move_dest_ids.filtered(lambda m: len(m.created_purchase_line_ids.ids) > 1) + if moves_to_unlink: + moves_to_unlink.created_purchase_line_ids = [Command.unlink(order_line.id)] + move_dest_ids = order_line.move_dest_ids + if order_line.propagate_cancel: + move_dest_ids._action_cancel() + else: + move_dest_ids.write({'procure_method': 'make_to_stock'}) + move_dest_ids._recompute_state() + + for pick in order.picking_ids.filtered(lambda r: r.state != 'cancel'): + pick.action_cancel() + + order.order_line.write({'move_dest_ids':[(5,0,0)]}) + + return super(PurchaseOrder, self).button_cancel() + + def action_view_picking(self): + return self._get_action_view_picking(self.picking_ids) + + def _get_action_view_picking(self, pickings): + """ This function returns an action that display existing picking orders of given purchase order ids. When only one found, show the picking immediately. + """ + self.ensure_one() + result = self.env["ir.actions.actions"]._for_xml_id('stock.action_picking_tree_all') + # override the context to get rid of the default filtering on operation type + result['context'] = {'default_partner_id': self.partner_id.id, 'default_origin': self.name, 'default_picking_type_id': self.picking_type_id.id} + # choose the view_mode accordingly + if not pickings or len(pickings) > 1: + result['domain'] = [('id', 'in', pickings.ids)] + elif len(pickings) == 1: + res = self.env.ref('stock.view_picking_form', False) + form_view = [(res and res.id or False, 'form')] + result['views'] = form_view + [(state, view) for state, view in result.get('views', []) if view != 'form'] + result['res_id'] = pickings.id + return result + + def _prepare_invoice(self): + invoice_vals = super()._prepare_invoice() + invoice_vals['invoice_incoterm_id'] = self.incoterm_id.id + return invoice_vals + + # -------------------------------------------------- + # Business methods + # -------------------------------------------------- + + def _log_decrease_ordered_quantity(self, purchase_order_lines_quantities): + + def _keys_in_groupby(move): + """ group by picking and the responsible for the product the + move. + """ + return (move.picking_id, move.product_id.responsible_id) + + def _render_note_exception_quantity_po(order_exceptions): + order_line_ids = self.env['purchase.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]]) + purchase_order_ids = order_line_ids.mapped('order_id') + move_ids = self.env['stock.move'].concat(*rendering_context.keys()) + impacted_pickings = move_ids.mapped('picking_id')._get_impacted_pickings(move_ids) - move_ids.mapped('picking_id') + values = { + 'purchase_order_ids': purchase_order_ids, + 'order_exceptions': order_exceptions.values(), + 'impacted_pickings': impacted_pickings, + } + return self.env['ir.qweb']._render('purchase_stock.exception_on_po', values) + + documents = self.env['stock.picking']._log_activity_get_documents(purchase_order_lines_quantities, 'move_ids', 'DOWN', _keys_in_groupby) + filtered_documents = {} + for (parent, responsible), rendering_context in documents.items(): + if parent._name == 'stock.picking': + if parent.state in ['cancel', 'done']: + continue + filtered_documents[(parent, responsible)] = rendering_context + self.env['stock.picking']._log_activity(_render_note_exception_quantity_po, filtered_documents) + + def _get_destination_location(self): + self.ensure_one() + if self.dest_address_id: + return self.dest_address_id.property_stock_customer.id + return self.picking_type_id.default_location_dest_id.id + + @api.model + def _get_picking_type(self, company_id): + picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id.company_id', '=', company_id)]) + if not picking_type: + picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id', '=', False)]) + return picking_type[:1] + + def _prepare_picking(self): + if not self.group_id: + self.group_id = self.group_id.create({ + 'name': self.name, + 'partner_id': self.partner_id.id + }) + if not self.partner_id.property_stock_supplier.id: + raise UserError(_("You must set a Vendor Location for this partner %s", self.partner_id.name)) + return { + 'picking_type_id': self.picking_type_id.id, + 'partner_id': self.partner_id.id, + 'user_id': False, + 'date': self.date_order, + 'origin': self.name, + 'location_dest_id': self._get_destination_location(), + 'location_id': self.partner_id.property_stock_supplier.id, + 'company_id': self.company_id.id, + 'state': 'draft', + } + + def _create_picking(self): + StockPicking = self.env['stock.picking'] + for order in self.filtered(lambda po: po.state in ('purchase', 'done')): + if any(product.type in ['product', 'consu'] for product in order.order_line.product_id): + order = order.with_company(order.company_id) + pickings = order.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel')) + if not pickings: + res = order._prepare_picking() + picking = StockPicking.with_user(SUPERUSER_ID).create(res) + pickings = picking + else: + picking = pickings[0] + moves = order.order_line._create_stock_moves(picking) + moves = moves.filtered(lambda x: x.state not in ('done', 'cancel'))._action_confirm() + seq = 0 + for move in sorted(moves, key=lambda move: move.date): + seq += 5 + move.sequence = seq + moves._action_assign() + # Get following pickings (created by push rules) to confirm them as well. + forward_pickings = self.env['stock.picking']._get_impacted_pickings(moves) + (pickings | forward_pickings).action_confirm() + picking.message_post_with_source( + 'mail.message_origin_link', + render_values={'self': picking, 'origin': order}, + subtype_xmlid='mail.mt_note', + ) + return True + + def _add_picking_info(self, activity): + """Helper method to add picking info to the Date Updated activity when + vender updates date_planned of the po lines. + """ + validated_picking = self.picking_ids.filtered(lambda p: p.state == 'done') + if validated_picking: + message = _("Those dates couldn’t be modified accordingly on the receipt %s which had already been validated.", validated_picking[0].name) + elif not self.picking_ids: + message = _("Corresponding receipt not found.") + else: + message = _("Those dates have been updated accordingly on the receipt %s.", self.picking_ids[0].name) + activity.note += Markup('

{}

').format(message) + + def _create_update_date_activity(self, updated_dates): + activity = super()._create_update_date_activity(updated_dates) + self._add_picking_info(activity) + + def _update_update_date_activity(self, updated_dates, activity): + # remove old picking info to update it + note_lines = activity.note.split('

') + note_lines.pop() + activity.note = Markup('

').join(note_lines) + super()._update_update_date_activity(updated_dates, activity) + self._add_picking_info(activity) + + @api.model + def _get_orders_to_remind(self): + """When auto sending reminder mails, don't send for purchase order with + validated receipts.""" + return super()._get_orders_to_remind().filtered(lambda p: not p.effective_date) diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase_order_line.py similarity index 58% rename from addons/purchase_stock/models/purchase.py rename to addons/purchase_stock/models/purchase_order_line.py index 8f425d09516..fd2c8eceed9 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase_order_line.py @@ -1,291 +1,10 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from markupsafe import Markup -from odoo import api, Command, fields, models, SUPERUSER_ID, _ +from odoo import api, Command, fields, models, _ from odoo.tools.float_utils import float_compare, float_is_zero, float_round from odoo.exceptions import UserError -class PurchaseOrder(models.Model): - _inherit = 'purchase.order' - - @api.model - def _default_picking_type(self): - return self._get_picking_type(self.env.context.get('company_id') or self.env.company.id) - - incoterm_location = fields.Char(string='Incoterm Location') - incoming_picking_count = fields.Integer("Incoming Shipment count", compute='_compute_incoming_picking_count') - picking_ids = fields.Many2many('stock.picking', compute='_compute_picking_ids', string='Receptions', copy=False, store=True) - dest_address_id = fields.Many2one('res.partner', compute='_compute_dest_address_id', store=True, readonly=False) - picking_type_id = fields.Many2one('stock.picking.type', 'Deliver To', required=True, default=_default_picking_type, domain="['|', ('warehouse_id', '=', False), ('warehouse_id.company_id', '=', company_id)]", - help="This will determine operation type of incoming shipment") - default_location_dest_id_usage = fields.Selection(related='picking_type_id.default_location_dest_id.usage', string='Destination Location Type', - help="Technical field used to display the Drop Ship Address", readonly=True) - group_id = fields.Many2one('procurement.group', string="Procurement Group", copy=False) - is_shipped = fields.Boolean(compute="_compute_is_shipped") - effective_date = fields.Datetime("Arrival", compute='_compute_effective_date', store=True, copy=False, - help="Completion date of the first receipt order.") - on_time_rate = fields.Float(related='partner_id.on_time_rate', compute_sudo=False) - receipt_status = fields.Selection([ - ('pending', 'Not Received'), - ('partial', 'Partially Received'), - ('full', 'Fully Received'), - ], string='Receipt Status', compute='_compute_receipt_status', store=True) - - @api.depends('order_line.move_ids.picking_id') - def _compute_picking_ids(self): - for order in self: - order.picking_ids = order.order_line.move_ids.picking_id - - @api.depends('picking_ids') - def _compute_incoming_picking_count(self): - for order in self: - order.incoming_picking_count = len(order.picking_ids) - - @api.depends('picking_ids.date_done') - def _compute_effective_date(self): - for order in self: - pickings = order.picking_ids.filtered(lambda x: x.state == 'done' and x.location_dest_id.usage != 'supplier' and x.date_done) - order.effective_date = min(pickings.mapped('date_done'), default=False) - - @api.depends('picking_ids', 'picking_ids.state') - def _compute_is_shipped(self): - for order in self: - if order.picking_ids and all(x.state in ['done', 'cancel'] for x in order.picking_ids): - order.is_shipped = True - else: - order.is_shipped = False - - @api.depends('picking_ids', 'picking_ids.state') - def _compute_receipt_status(self): - for order in self: - if not order.picking_ids or all(p.state == 'cancel' for p in order.picking_ids): - order.receipt_status = False - elif all(p.state in ['done', 'cancel'] for p in order.picking_ids): - order.receipt_status = 'full' - elif any(p.state == 'done' for p in order.picking_ids): - order.receipt_status = 'partial' - else: - order.receipt_status = 'pending' - - @api.depends('picking_type_id') - def _compute_dest_address_id(self): - self.filtered(lambda po: po.picking_type_id.default_location_dest_id.usage != 'customer').dest_address_id = False - - @api.onchange('company_id') - def _onchange_company_id(self): - p_type = self.picking_type_id - if not(p_type and p_type.code == 'incoming' and (p_type.warehouse_id.company_id == self.company_id or not p_type.warehouse_id)): - self.picking_type_id = self._get_picking_type(self.company_id.id) - - # -------------------------------------------------- - # CRUD - # -------------------------------------------------- - - def write(self, vals): - if vals.get('order_line') and self.state == 'purchase': - for order in self: - pre_order_line_qty = {order_line: order_line.product_qty for order_line in order.mapped('order_line')} - res = super(PurchaseOrder, self).write(vals) - if vals.get('order_line') and self.state == 'purchase': - for order in self: - to_log = {} - for order_line in order.order_line: - if pre_order_line_qty.get(order_line, False) and float_compare(pre_order_line_qty[order_line], order_line.product_qty, precision_rounding=order_line.product_uom.rounding) > 0: - to_log[order_line] = (order_line.product_qty, pre_order_line_qty[order_line]) - if to_log: - order._log_decrease_ordered_quantity(to_log) - return res - - # -------------------------------------------------- - # Actions - # -------------------------------------------------- - - def button_approve(self, force=False): - result = super(PurchaseOrder, self).button_approve(force=force) - self._create_picking() - return result - - def button_cancel(self): - for order in self: - for move in order.order_line.mapped('move_ids'): - if move.state == 'done': - raise UserError(_('Unable to cancel purchase order %s as some receptions have already been done.', order.name)) - # If the product is MTO, change the procure_method of the closest move to purchase to MTS. - # The purpose is to link the po that the user will manually generate to the existing moves's chain. - if order.state in ('draft', 'sent', 'to approve', 'purchase'): - for order_line in order.order_line: - order_line.move_ids._action_cancel() - if order_line.move_dest_ids: - moves_to_unlink = order_line.move_dest_ids.filtered(lambda m: len(m.created_purchase_line_ids.ids) > 1) - if moves_to_unlink: - moves_to_unlink.created_purchase_line_ids = [Command.unlink(order_line.id)] - move_dest_ids = order_line.move_dest_ids - if order_line.propagate_cancel: - move_dest_ids._action_cancel() - else: - move_dest_ids.write({'procure_method': 'make_to_stock'}) - move_dest_ids._recompute_state() - - for pick in order.picking_ids.filtered(lambda r: r.state != 'cancel'): - pick.action_cancel() - - order.order_line.write({'move_dest_ids':[(5,0,0)]}) - - return super(PurchaseOrder, self).button_cancel() - - def action_view_picking(self): - return self._get_action_view_picking(self.picking_ids) - - def _get_action_view_picking(self, pickings): - """ This function returns an action that display existing picking orders of given purchase order ids. When only one found, show the picking immediately. - """ - self.ensure_one() - result = self.env["ir.actions.actions"]._for_xml_id('stock.action_picking_tree_all') - # override the context to get rid of the default filtering on operation type - result['context'] = {'default_partner_id': self.partner_id.id, 'default_origin': self.name, 'default_picking_type_id': self.picking_type_id.id} - # choose the view_mode accordingly - if not pickings or len(pickings) > 1: - result['domain'] = [('id', 'in', pickings.ids)] - elif len(pickings) == 1: - res = self.env.ref('stock.view_picking_form', False) - form_view = [(res and res.id or False, 'form')] - result['views'] = form_view + [(state, view) for state, view in result.get('views', []) if view != 'form'] - result['res_id'] = pickings.id - return result - - def _prepare_invoice(self): - invoice_vals = super()._prepare_invoice() - invoice_vals['invoice_incoterm_id'] = self.incoterm_id.id - return invoice_vals - - # -------------------------------------------------- - # Business methods - # -------------------------------------------------- - - def _log_decrease_ordered_quantity(self, purchase_order_lines_quantities): - - def _keys_in_groupby(move): - """ group by picking and the responsible for the product the - move. - """ - return (move.picking_id, move.product_id.responsible_id) - - def _render_note_exception_quantity_po(order_exceptions): - order_line_ids = self.env['purchase.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]]) - purchase_order_ids = order_line_ids.mapped('order_id') - move_ids = self.env['stock.move'].concat(*rendering_context.keys()) - impacted_pickings = move_ids.mapped('picking_id')._get_impacted_pickings(move_ids) - move_ids.mapped('picking_id') - values = { - 'purchase_order_ids': purchase_order_ids, - 'order_exceptions': order_exceptions.values(), - 'impacted_pickings': impacted_pickings, - } - return self.env['ir.qweb']._render('purchase_stock.exception_on_po', values) - - documents = self.env['stock.picking']._log_activity_get_documents(purchase_order_lines_quantities, 'move_ids', 'DOWN', _keys_in_groupby) - filtered_documents = {} - for (parent, responsible), rendering_context in documents.items(): - if parent._name == 'stock.picking': - if parent.state in ['cancel', 'done']: - continue - filtered_documents[(parent, responsible)] = rendering_context - self.env['stock.picking']._log_activity(_render_note_exception_quantity_po, filtered_documents) - - def _get_destination_location(self): - self.ensure_one() - if self.dest_address_id: - return self.dest_address_id.property_stock_customer.id - return self.picking_type_id.default_location_dest_id.id - - @api.model - def _get_picking_type(self, company_id): - picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id.company_id', '=', company_id)]) - if not picking_type: - picking_type = self.env['stock.picking.type'].search([('code', '=', 'incoming'), ('warehouse_id', '=', False)]) - return picking_type[:1] - - def _prepare_picking(self): - if not self.group_id: - self.group_id = self.group_id.create({ - 'name': self.name, - 'partner_id': self.partner_id.id - }) - if not self.partner_id.property_stock_supplier.id: - raise UserError(_("You must set a Vendor Location for this partner %s", self.partner_id.name)) - return { - 'picking_type_id': self.picking_type_id.id, - 'partner_id': self.partner_id.id, - 'user_id': False, - 'date': self.date_order, - 'origin': self.name, - 'location_dest_id': self._get_destination_location(), - 'location_id': self.partner_id.property_stock_supplier.id, - 'company_id': self.company_id.id, - } - - def _create_picking(self): - StockPicking = self.env['stock.picking'] - for order in self.filtered(lambda po: po.state in ('purchase', 'done')): - if any(product.type in ['product', 'consu'] for product in order.order_line.product_id): - order = order.with_company(order.company_id) - pickings = order.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel')) - if not pickings: - res = order._prepare_picking() - picking = StockPicking.with_user(SUPERUSER_ID).create(res) - pickings = picking - else: - picking = pickings[0] - moves = order.order_line._create_stock_moves(picking) - moves = moves.filtered(lambda x: x.state not in ('done', 'cancel'))._action_confirm() - seq = 0 - for move in sorted(moves, key=lambda move: move.date): - seq += 5 - move.sequence = seq - moves._action_assign() - # Get following pickings (created by push rules) to confirm them as well. - forward_pickings = self.env['stock.picking']._get_impacted_pickings(moves) - (pickings | forward_pickings).action_confirm() - picking.message_post_with_source( - 'mail.message_origin_link', - render_values={'self': picking, 'origin': order}, - subtype_xmlid='mail.mt_note', - ) - return True - - def _add_picking_info(self, activity): - """Helper method to add picking info to the Date Updated activity when - vender updates date_planned of the po lines. - """ - validated_picking = self.picking_ids.filtered(lambda p: p.state == 'done') - if validated_picking: - message = _("Those dates couldn’t be modified accordingly on the receipt %s which had already been validated.", validated_picking[0].name) - elif not self.picking_ids: - message = _("Corresponding receipt not found.") - else: - message = _("Those dates have been updated accordingly on the receipt %s.", self.picking_ids[0].name) - activity.note += Markup('

{}

').format(message) - - def _create_update_date_activity(self, updated_dates): - activity = super()._create_update_date_activity(updated_dates) - self._add_picking_info(activity) - - def _update_update_date_activity(self, updated_dates, activity): - # remove old picking info to update it - note_lines = activity.note.split('

') - note_lines.pop() - activity.note = Markup('

').join(note_lines) - super()._update_update_date_activity(updated_dates, activity) - self._add_picking_info(activity) - - @api.model - def _get_orders_to_remind(self): - """When auto sending reminder mails, don't send for purchase order with - validated receipts.""" - return super()._get_orders_to_remind().filtered(lambda p: not p.effective_date) - - class PurchaseOrderLine(models.Model): _inherit = 'purchase.order.line' diff --git a/addons/purchase_stock/tests/test_uninstall.py b/addons/purchase_stock/tests/test_uninstall.py index d7da6808c0a..7864e03646c 100644 --- a/addons/purchase_stock/tests/test_uninstall.py +++ b/addons/purchase_stock/tests/test_uninstall.py @@ -4,7 +4,7 @@ from unittest.mock import patch from odoo import fields from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG -from odoo.addons.purchase_stock.models.purchase import PurchaseOrderLine +from odoo.addons.purchase_stock.models.purchase_order_line import PurchaseOrderLine from odoo.tests.common import tagged from .common import PurchaseTestCommon