diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_stock/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sale_stock/models')
| -rw-r--r-- | addons/sale_stock/models/__init__.py | 10 | ||||
| -rw-r--r-- | addons/sale_stock/models/account_move.py | 125 | ||||
| -rw-r--r-- | addons/sale_stock/models/product_template.py | 19 | ||||
| -rw-r--r-- | addons/sale_stock/models/res_company.py | 15 | ||||
| -rw-r--r-- | addons/sale_stock/models/res_config_settings.py | 26 | ||||
| -rw-r--r-- | addons/sale_stock/models/res_users.py | 33 | ||||
| -rw-r--r-- | addons/sale_stock/models/sale_order.py | 640 | ||||
| -rw-r--r-- | addons/sale_stock/models/stock.py | 174 |
8 files changed, 1042 insertions, 0 deletions
diff --git a/addons/sale_stock/models/__init__.py b/addons/sale_stock/models/__init__.py new file mode 100644 index 00000000..e2983e92 --- /dev/null +++ b/addons/sale_stock/models/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_move +from . import res_company +from . import sale_order +from . import res_config_settings +from . import stock +from . import product_template +from . import res_users diff --git a/addons/sale_stock/models/account_move.py b/addons/sale_stock/models/account_move.py new file mode 100644 index 00000000..7827f827 --- /dev/null +++ b/addons/sale_stock/models/account_move.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import fields, models +from odoo.tools import float_is_zero + + +class AccountMove(models.Model): + _inherit = 'account.move' + + def _stock_account_get_last_step_stock_moves(self): + """ Overridden from stock_account. + Returns the stock moves associated to this invoice.""" + rslt = super(AccountMove, self)._stock_account_get_last_step_stock_moves() + for invoice in self.filtered(lambda x: x.move_type == 'out_invoice'): + rslt += invoice.mapped('invoice_line_ids.sale_line_ids.move_ids').filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'customer') + for invoice in self.filtered(lambda x: x.move_type == 'out_refund'): + rslt += invoice.mapped('reversed_entry_id.invoice_line_ids.sale_line_ids.move_ids').filtered(lambda x: x.state == 'done' and x.location_id.usage == 'customer') + # Add refunds generated from the SO + rslt += invoice.mapped('invoice_line_ids.sale_line_ids.move_ids').filtered(lambda x: x.state == 'done' and x.location_id.usage == 'customer') + return rslt + + def _get_invoiced_lot_values(self): + """ Get and prepare data to show a table of invoiced lot on the invoice's report. """ + self.ensure_one() + + if self.state == 'draft': + return [] + + sale_orders = self.mapped('invoice_line_ids.sale_line_ids.order_id') + stock_move_lines = sale_orders.mapped('picking_ids.move_lines.move_line_ids') + + # Get the other customer invoices and refunds. + ordered_invoice_ids = sale_orders.mapped('invoice_ids')\ + .filtered(lambda i: i.state not in ['draft', 'cancel'])\ + .sorted(lambda i: (i.invoice_date, i.id)) + + # Get the position of self in other customer invoices and refunds. + self_index = None + i = 0 + for invoice in ordered_invoice_ids: + if invoice.id == self.id: + self_index = i + break + i += 1 + + # Get the previous invoice if any. + previous_invoices = ordered_invoice_ids[:self_index] + last_invoice = previous_invoices[-1] if len(previous_invoices) else None + + # Get the incoming and outgoing sml between self.invoice_date and the previous invoice (if any). + write_dates = [wd for wd in self.invoice_line_ids.mapped('write_date') if wd] + self_datetime = max(write_dates) if write_dates else None + last_write_dates = last_invoice and [wd for wd in last_invoice.invoice_line_ids.mapped('write_date') if wd] + last_invoice_datetime = max(last_write_dates) if last_write_dates else None + def _filter_incoming_sml(ml): + if ml.state == 'done' and ml.location_id.usage == 'customer' and ml.lot_id: + if last_invoice_datetime: + return last_invoice_datetime <= ml.date <= self_datetime + else: + return ml.date <= self_datetime + return False + + def _filter_outgoing_sml(ml): + if ml.state == 'done' and ml.location_dest_id.usage == 'customer' and ml.lot_id: + if last_invoice_datetime: + return last_invoice_datetime <= ml.date <= self_datetime + else: + return ml.date <= self_datetime + return False + + incoming_sml = stock_move_lines.filtered(_filter_incoming_sml) + outgoing_sml = stock_move_lines.filtered(_filter_outgoing_sml) + + # Prepare and return lot_values + qties_per_lot = defaultdict(lambda: 0) + if self.move_type == 'out_refund': + for ml in outgoing_sml: + qties_per_lot[ml.lot_id] -= ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + for ml in incoming_sml: + qties_per_lot[ml.lot_id] += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + else: + for ml in outgoing_sml: + qties_per_lot[ml.lot_id] += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + for ml in incoming_sml: + qties_per_lot[ml.lot_id] -= ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + lot_values = [] + for lot_id, qty in qties_per_lot.items(): + if float_is_zero(qty, precision_rounding=lot_id.product_id.uom_id.rounding): + continue + lot_values.append({ + 'product_name': lot_id.product_id.display_name, + 'quantity': qty, + 'uom_name': lot_id.product_uom_id.name, + 'lot_name': lot_id.name, + # The lot id is needed by localizations to inherit the method and add custom fields on the invoice's report. + 'lot_id': lot_id.id + }) + return lot_values + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _sale_can_be_reinvoice(self): + self.ensure_one() + return not self.is_anglo_saxon_line and super(AccountMoveLine, self)._sale_can_be_reinvoice() + + + def _stock_account_get_anglo_saxon_price_unit(self): + self.ensure_one() + price_unit = super(AccountMoveLine, self)._stock_account_get_anglo_saxon_price_unit() + + so_line = self.sale_line_ids and self.sale_line_ids[-1] or False + if so_line: + qty_to_invoice = self.product_uom_id._compute_quantity(self.quantity, self.product_id.uom_id) + qty_invoiced = sum([x.product_uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in so_line.invoice_lines if x.move_id.state == 'posted']) + average_price_unit = self.product_id._compute_average_price(qty_invoiced, qty_to_invoice, so_line.move_ids) + + price_unit = average_price_unit or price_unit + price_unit = self.product_id.uom_id._compute_price(price_unit, self.product_uom_id) + return price_unit + diff --git a/addons/sale_stock/models/product_template.py b/addons/sale_stock/models/product_template.py new file mode 100644 index 00000000..11673475 --- /dev/null +++ b/addons/sale_stock/models/product_template.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + @api.onchange('type') + def _onchange_type(self): + """ We want to prevent storable product to be expensed, since it make no sense as when confirm + expenses, the product is already out of our stock. + """ + res = super(ProductTemplate, self)._onchange_type() + if self.type == 'product': + self.expense_policy = 'no' + self.service_type = 'manual' + return res diff --git a/addons/sale_stock/models/res_company.py b/addons/sale_stock/models/res_company.py new file mode 100644 index 00000000..1677868c --- /dev/null +++ b/addons/sale_stock/models/res_company.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class company(models.Model): + _inherit = 'res.company' + + security_lead = fields.Float( + 'Sales Safety Days', default=0.0, required=True, + help="Margin of error for dates promised to customers. " + "Products will be scheduled for procurement and delivery " + "that many days earlier than the actual promised date, to " + "cope with unexpected delays in the supply chain.") diff --git a/addons/sale_stock/models/res_config_settings.py b/addons/sale_stock/models/res_config_settings.py new file mode 100644 index 00000000..e0057775 --- /dev/null +++ b/addons/sale_stock/models/res_config_settings.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 ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + security_lead = fields.Float(related='company_id.security_lead', string="Security Lead Time", readonly=False) + group_display_incoterm = fields.Boolean("Incoterms", implied_group='sale_stock.group_display_incoterm') + group_lot_on_invoice = fields.Boolean("Display Lots & Serial Numbers on Invoices", + implied_group='sale_stock.group_lot_on_invoice') + use_security_lead = fields.Boolean( + string="Security Lead Time for Sales", + config_parameter='sale_stock.use_security_lead', + help="Margin of error for dates promised to customers. Products will be scheduled for delivery that many days earlier than the actual promised date, to cope with unexpected delays in the supply chain.") + default_picking_policy = fields.Selection([ + ('direct', 'Ship products as soon as available, with back orders'), + ('one', 'Ship all products at once') + ], "Picking Policy", default='direct', default_model="sale.order", required=True) + + @api.onchange('use_security_lead') + def _onchange_use_security_lead(self): + if not self.use_security_lead: + self.security_lead = 0.0 diff --git a/addons/sale_stock/models/res_users.py b/addons/sale_stock/models/res_users.py new file mode 100644 index 00000000..e1498ba2 --- /dev/null +++ b/addons/sale_stock/models/res_users.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields + + +class Users(models.Model): + _inherit = ['res.users'] + + property_warehouse_id = fields.Many2one('stock.warehouse', string='Default Warehouse', company_dependent=True, check_company=True) + + def _get_default_warehouse_id(self): + if self.property_warehouse_id: + return self.property_warehouse_id + # !!! Any change to the following search domain should probably + # be also applied in sale_stock/models/sale_order.py/_init_column. + return self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1) + + def __init__(self, pool, cr): + """ Override of __init__ to add access rights. + Access rights are disabled by default, but allowed + on some specific fields defined in self.SELF_{READ/WRITE}ABLE_FIELDS. + """ + + sale_stock_writeable_fields = [ + 'property_warehouse_id', + ] + + init_res = super().__init__(pool, cr) + # duplicate list to avoid modifying the original reference + type(self).SELF_READABLE_FIELDS = type(self).SELF_READABLE_FIELDS + sale_stock_writeable_fields + type(self).SELF_WRITEABLE_FIELDS = type(self).SELF_WRITEABLE_FIELDS + sale_stock_writeable_fields + return init_res diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py new file mode 100644 index 00000000..8a9a2f28 --- /dev/null +++ b/addons/sale_stock/models/sale_order.py @@ -0,0 +1,640 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging +from datetime import datetime, timedelta +from collections import defaultdict + +from odoo import api, fields, models, _ +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare, float_round +from odoo.tools.float_utils import float_repr +from odoo.tools.misc import format_date +from odoo.exceptions import UserError + + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + @api.model + def _default_warehouse_id(self): + # !!! Any change to the default value may have to be repercuted + # on _init_column() below. + return self.env.user._get_default_warehouse_id() + + incoterm = fields.Many2one( + 'account.incoterms', 'Incoterm', + help="International Commercial Terms are a series of predefined commercial terms used in international transactions.") + picking_policy = fields.Selection([ + ('direct', 'As soon as possible'), + ('one', 'When all products are ready')], + string='Shipping Policy', required=True, readonly=True, default='direct', + states={'draft': [('readonly', False)], 'sent': [('readonly', False)]} + ,help="If you deliver all products at once, the delivery order will be scheduled based on the greatest " + "product lead time. Otherwise, it will be based on the shortest.") + warehouse_id = fields.Many2one( + 'stock.warehouse', string='Warehouse', + required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + default=_default_warehouse_id, check_company=True) + picking_ids = fields.One2many('stock.picking', 'sale_id', string='Transfers') + delivery_count = fields.Integer(string='Delivery Orders', compute='_compute_picking_ids') + procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False) + effective_date = fields.Date("Effective Date", compute='_compute_effective_date', store=True, help="Completion date of the first delivery order.") + expected_date = fields.Datetime( help="Delivery date you can promise to the customer, computed from the minimum lead time of " + "the order lines in case of Service products. In case of shipping, the shipping policy of " + "the order will be taken into account to either use the minimum or maximum lead time of " + "the order lines.") + json_popover = fields.Char('JSON data for the popover widget', compute='_compute_json_popover') + show_json_popover = fields.Boolean('Has late picking', compute='_compute_json_popover') + + def _init_column(self, column_name): + """ Ensure the default warehouse_id is correctly assigned + + At column initialization, the ir.model.fields for res.users.property_warehouse_id isn't created, + which means trying to read the property field to get the default value will crash. + We therefore enforce the default here, without going through + the default function on the warehouse_id field. + """ + if column_name != "warehouse_id": + return super(SaleOrder, self)._init_column(column_name) + field = self._fields[column_name] + default = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1) + value = field.convert_to_write(default, self) + value = field.convert_to_column(value, self) + if value is not None: + _logger.debug("Table '%s': setting default value of new column %s to %r", + self._table, column_name, value) + query = 'UPDATE "%s" SET "%s"=%s WHERE "%s" IS NULL' % ( + self._table, column_name, field.column_format, column_name) + self._cr.execute(query, (value,)) + + @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 == 'customer') + dates_list = [date for date in pickings.mapped('date_done') if date] + order.effective_date = min(dates_list).date() if dates_list else False + + @api.depends('picking_policy') + def _compute_expected_date(self): + super(SaleOrder, self)._compute_expected_date() + for order in self: + dates_list = [] + for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type): + dt = line._expected_date() + dates_list.append(dt) + if dates_list: + expected_date = min(dates_list) if order.picking_policy == 'direct' else max(dates_list) + order.expected_date = fields.Datetime.to_string(expected_date) + + @api.model + def create(self, vals): + if 'warehouse_id' not in vals and 'company_id' in vals: + user = self.env['res.users'].browse(vals.get('user_id', False)) + vals['warehouse_id'] = user.with_company(vals.get('company_id'))._get_default_warehouse_id().id + return super().create(vals) + + def write(self, values): + if values.get('order_line') and self.state == 'sale': + for order in self: + pre_order_line_qty = {order_line: order_line.product_uom_qty for order_line in order.mapped('order_line') if not order_line.is_expense} + + if values.get('partner_shipping_id'): + new_partner = self.env['res.partner'].browse(values.get('partner_shipping_id')) + for record in self: + picking = record.mapped('picking_ids').filtered(lambda x: x.state not in ('done', 'cancel')) + addresses = (record.partner_shipping_id.display_name, new_partner.display_name) + message = _("""The delivery address has been changed on the Sales Order<br/> + From <strong>"%s"</strong> To <strong>"%s"</strong>, + You should probably update the partner on this document.""") % addresses + picking.activity_schedule('mail.mail_activity_data_warning', note=message, user_id=self.env.user.id) + + if values.get('commitment_date'): + # protagate commitment_date as the deadline of the related stock move. + # TODO: Log a note on each down document + self.order_line.move_ids.date_deadline = fields.Datetime.to_datetime(values.get('commitment_date')) + + res = super(SaleOrder, self).write(values) + if values.get('order_line') and self.state == 'sale': + for order in self: + to_log = {} + for order_line in order.order_line: + if float_compare(order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0), order_line.product_uom.rounding) < 0: + to_log[order_line] = (order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0)) + if to_log: + documents = self.env['stock.picking']._log_activity_get_documents(to_log, 'move_ids', 'UP') + documents = {k:v for k, v in documents.items() if k[0].state != 'cancel'} + order._log_decrease_ordered_quantity(documents) + return res + + def _compute_json_popover(self): + for order in self: + late_stock_picking = order.picking_ids.filtered(lambda p: p.delay_alert_date) + order.json_popover = json.dumps({ + 'popoverTemplate': 'sale_stock.DelayAlertWidget', + 'late_elements': [{ + 'id': late_move.id, + 'name': late_move.display_name, + 'model': 'stock.picking', + } for late_move in late_stock_picking + ] + }) + order.show_json_popover = bool(late_stock_picking) + + def _action_confirm(self): + self.order_line._action_launch_stock_rule() + return super(SaleOrder, self)._action_confirm() + + @api.depends('picking_ids') + def _compute_picking_ids(self): + for order in self: + order.delivery_count = len(order.picking_ids) + + @api.onchange('company_id') + def _onchange_company_id(self): + if self.company_id: + warehouse_id = self.env['ir.default'].get_model_defaults('sale.order').get('warehouse_id') + self.warehouse_id = warehouse_id or self.user_id.with_company(self.company_id.id)._get_default_warehouse_id().id + + @api.onchange('user_id') + def onchange_user_id(self): + super().onchange_user_id() + self.warehouse_id = self.user_id.with_company(self.company_id.id)._get_default_warehouse_id().id + + @api.onchange('partner_shipping_id') + def _onchange_partner_shipping_id(self): + res = {} + pickings = self.picking_ids.filtered( + lambda p: p.state not in ['done', 'cancel'] and p.partner_id != self.partner_shipping_id + ) + if pickings: + res['warning'] = { + 'title': _('Warning!'), + 'message': _( + 'Do not forget to change the partner on the following delivery orders: %s' + ) % (','.join(pickings.mapped('name'))) + } + return res + + def action_view_delivery(self): + ''' + This function returns an action that display existing delivery orders + of given sales order ids. It can either be a in a list or in a form + view, if there is only one delivery order to show. + ''' + action = self.env["ir.actions.actions"]._for_xml_id("stock.action_picking_tree_all") + + pickings = self.mapped('picking_ids') + if len(pickings) > 1: + action['domain'] = [('id', 'in', pickings.ids)] + elif pickings: + form_view = [(self.env.ref('stock.view_picking_form').id, 'form')] + if 'views' in action: + action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form'] + else: + action['views'] = form_view + action['res_id'] = pickings.id + # Prepare the context. + picking_id = pickings.filtered(lambda l: l.picking_type_id.code == 'outgoing') + if picking_id: + picking_id = picking_id[0] + else: + picking_id = pickings[0] + action['context'] = dict(self._context, default_partner_id=self.partner_id.id, default_picking_type_id=picking_id.picking_type_id.id, default_origin=self.name, default_group_id=picking_id.group_id.id) + return action + + def action_cancel(self): + documents = None + for sale_order in self: + if sale_order.state == 'sale' and sale_order.order_line: + sale_order_lines_quantities = {order_line: (order_line.product_uom_qty, 0) for order_line in sale_order.order_line} + documents = self.env['stock.picking']._log_activity_get_documents(sale_order_lines_quantities, 'move_ids', 'UP') + self.picking_ids.filtered(lambda p: p.state != 'done').action_cancel() + if documents: + filtered_documents = {} + for (parent, responsible), rendering_context in documents.items(): + if parent._name == 'stock.picking': + if parent.state == 'cancel': + continue + filtered_documents[(parent, responsible)] = rendering_context + self._log_decrease_ordered_quantity(filtered_documents, cancel=True) + return super(SaleOrder, self).action_cancel() + + def _prepare_invoice(self): + invoice_vals = super(SaleOrder, self)._prepare_invoice() + invoice_vals['invoice_incoterm_id'] = self.incoterm.id + return invoice_vals + + @api.model + def _get_customer_lead(self, product_tmpl_id): + super(SaleOrder, self)._get_customer_lead(product_tmpl_id) + return product_tmpl_id.sale_delay + + def _log_decrease_ordered_quantity(self, documents, cancel=False): + + def _render_note_exception_quantity_so(rendering_context): + order_exceptions, visited_moves = rendering_context + visited_moves = list(visited_moves) + visited_moves = self.env[visited_moves[0]._name].concat(*visited_moves) + order_line_ids = self.env['sale.order.line'].browse([order_line.id for order in order_exceptions.values() for order_line in order[0]]) + sale_order_ids = order_line_ids.mapped('order_id') + impacted_pickings = visited_moves.filtered(lambda m: m.state not in ('done', 'cancel')).mapped('picking_id') + values = { + 'sale_order_ids': sale_order_ids, + 'order_exceptions': order_exceptions.values(), + 'impacted_pickings': impacted_pickings, + 'cancel': cancel + } + return self.env.ref('sale_stock.exception_on_so')._render(values=values) + + self.env['stock.picking']._log_activity(_render_note_exception_quantity_so, documents) + + def _show_cancel_wizard(self): + res = super(SaleOrder, self)._show_cancel_wizard() + for order in self: + if any(picking.state == 'done' for picking in order.picking_ids) and not order._context.get('disable_cancel_warning'): + return True + return res + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')]) + product_packaging = fields.Many2one( 'product.packaging', string='Package', default=False, check_company=True) + route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True) + move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves') + product_type = fields.Selection(related='product_id.type') + virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure') + scheduled_date = fields.Datetime(compute='_compute_qty_at_date') + forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date') + free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure') + qty_available_today = fields.Float(compute='_compute_qty_at_date') + warehouse_id = fields.Many2one(related='order_id.warehouse_id') + qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure') + is_mto = fields.Boolean(compute='_compute_is_mto') + display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver') + + @api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom') + def _compute_qty_to_deliver(self): + """Compute the visibility of the inventory widget.""" + for line in self: + line.qty_to_deliver = line.product_uom_qty - line.qty_delivered + if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0: + if line.state == 'sale' and not line.move_ids: + line.display_qty_widget = False + else: + line.display_qty_widget = True + else: + line.display_qty_widget = False + + @api.depends( + 'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date', + 'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability') + def _compute_qty_at_date(self): + """ Compute the quantity forecasted of product at delivery date. There are + two cases: + 1. The quotation has a commitment_date, we take it as delivery date + 2. The quotation hasn't commitment_date, we compute the estimated delivery + date based on lead time""" + treated = self.browse() + # If the state is already in sale the picking is created and a simple forecasted quantity isn't enough + # Then used the forecasted data of the related stock.move + for line in self.filtered(lambda l: l.state == 'sale'): + if not line.display_qty_widget: + continue + moves = line.move_ids.filtered(lambda m: m.product_id == line.product_id) + line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False) + line.qty_available_today = 0 + line.free_qty_today = 0 + for move in moves: + line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom) + line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom) + line.scheduled_date = line.order_id.commitment_date or line._expected_date() + line.virtual_available_at_date = False + treated |= line + + qty_processed_per_product = defaultdict(lambda: 0) + grouped_lines = defaultdict(lambda: self.env['sale.order.line']) + # We first loop over the SO lines to group them by warehouse and schedule + # date in order to batch the read of the quantities computed field. + for line in self.filtered(lambda l: l.state in ('draft', 'sent')): + if not (line.product_id and line.display_qty_widget): + continue + grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line + + for (warehouse, scheduled_date), lines in grouped_lines.items(): + product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([ + 'qty_available', + 'free_qty', + 'virtual_available', + ]) + qties_per_product = { + product['id']: (product['qty_available'], product['free_qty'], product['virtual_available']) + for product in product_qties + } + for line in lines: + line.scheduled_date = scheduled_date + qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id] + line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id] + line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id] + line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id] + line.forecast_expected_date = False + product_qty = line.product_uom_qty + if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id: + line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom) + line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom) + line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom) + product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id) + qty_processed_per_product[line.product_id.id] += product_qty + treated |= lines + remaining = (self - treated) + remaining.virtual_available_at_date = False + remaining.scheduled_date = False + remaining.forecast_expected_date = False + remaining.free_qty_today = False + remaining.qty_available_today = False + + @api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids') + def _compute_is_mto(self): + """ Verify the route of the product based on the warehouse + set 'is_available' at True if the product availibility in stock does + not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping + """ + self.is_mto = False + for line in self: + if not line.display_qty_widget: + continue + product = line.product_id + product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids) + + # Check MTO + mto_route = line.order_id.warehouse_id.mto_pull_id.route_id + if not mto_route: + try: + mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order')) + except UserError: + # if route MTO not found in ir_model_data, we treat the product as in MTS + pass + + if mto_route and mto_route in product_routes: + line.is_mto = True + else: + line.is_mto = False + + @api.depends('product_id') + def _compute_qty_delivered_method(self): + """ Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])] + For SO line coming from expense, no picking should be generate: we don't manage stock for + thoses lines, even if the product is a storable. + """ + super(SaleOrderLine, self)._compute_qty_delivered_method() + + for line in self: + if not line.is_expense and line.product_id.type in ['consu', 'product']: + line.qty_delivered_method = 'stock_move' + + @api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom') + def _compute_qty_delivered(self): + super(SaleOrderLine, self)._compute_qty_delivered() + + for line in self: # TODO: maybe one day, this should be done in SQL for performance sake + if line.qty_delivered_method == 'stock_move': + qty = 0.0 + outgoing_moves, incoming_moves = line._get_outgoing_incoming_moves() + for move in outgoing_moves: + if move.state != 'done': + continue + qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP') + for move in incoming_moves: + if move.state != 'done': + continue + qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP') + line.qty_delivered = qty + + @api.model_create_multi + def create(self, vals_list): + lines = super(SaleOrderLine, self).create(vals_list) + lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule() + return lines + + def write(self, values): + lines = self.env['sale.order.line'] + if 'product_uom_qty' in values: + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + lines = self.filtered( + lambda r: r.state == 'sale' and not r.is_expense and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) + previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines} + res = super(SaleOrderLine, self).write(values) + if lines: + lines._action_launch_stock_rule(previous_product_uom_qty) + if 'customer_lead' in values and self.state == 'sale' and not self.order_id.commitment_date: + # Propagate deadline on related stock move + self.move_ids.date_deadline = self.order_id.date_order + timedelta(days=self.customer_lead or 0.0) + return res + + @api.depends('order_id.state') + def _compute_invoice_status(self): + def check_moves_state(moves): + # All moves states are either 'done' or 'cancel', and there is at least one 'done' + at_least_one_done = False + for move in moves: + if move.state not in ['done', 'cancel']: + return False + at_least_one_done = at_least_one_done or move.state == 'done' + return at_least_one_done + super(SaleOrderLine, self)._compute_invoice_status() + for line in self: + # We handle the following specific situation: a physical product is partially delivered, + # but we would like to set its invoice status to 'Fully Invoiced'. The use case is for + # products sold by weight, where the delivered quantity rarely matches exactly the + # quantity ordered. + if line.order_id.state == 'done'\ + and line.invoice_status == 'no'\ + and line.product_id.type in ['consu', 'product']\ + and line.product_id.invoice_policy == 'delivery'\ + and line.move_ids \ + and check_moves_state(line.move_ids): + line.invoice_status = 'invoiced' + + @api.depends('move_ids') + def _compute_product_updatable(self): + for line in self: + if not line.move_ids.filtered(lambda m: m.state != 'cancel'): + super(SaleOrderLine, line)._compute_product_updatable() + else: + line.product_updatable = False + + @api.onchange('product_id') + def _onchange_product_id_set_customer_lead(self): + self.customer_lead = self.product_id.sale_delay + + @api.onchange('product_packaging') + def _onchange_product_packaging(self): + if self.product_packaging: + return self._check_package() + + @api.onchange('product_uom_qty') + def _onchange_product_uom_qty(self): + # When modifying a one2many, _origin doesn't guarantee that its values will be the ones + # in database. Hence, we need to explicitly read them from there. + if self._origin: + product_uom_qty_origin = self._origin.read(["product_uom_qty"])[0]["product_uom_qty"] + else: + product_uom_qty_origin = 0 + + if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < product_uom_qty_origin: + # Do not display this warning if the new quantity is below the delivered + # one; the `write` will raise an `UserError` anyway. + 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 delivery order if needed.'), + } + return {'warning': warning_mess} + return {} + + def _prepare_procurement_values(self, group_id=False): + """ Prepare specific key for moves or other components that will be created from a stock rule + comming from a sale order line. This method could be override in order to add other custom key that could + be used in move/po creation. + """ + values = super(SaleOrderLine, self)._prepare_procurement_values(group_id) + self.ensure_one() + # Use the delivery date if there is else use date_order and lead time + date_deadline = self.order_id.commitment_date or (self.order_id.date_order + timedelta(days=self.customer_lead or 0.0)) + date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead) + values.update({ + 'group_id': group_id, + 'sale_line_id': self.id, + 'date_planned': date_planned, + 'date_deadline': date_deadline, + 'route_ids': self.route_id, + 'warehouse_id': self.order_id.warehouse_id or False, + 'partner_id': self.order_id.partner_shipping_id.id, + 'product_description_variants': self._get_sale_order_line_multiline_description_variants(), + 'company_id': self.order_id.company_id, + }) + return values + + def _get_qty_procurement(self, previous_product_uom_qty=False): + self.ensure_one() + qty = 0.0 + outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves() + for move in outgoing_moves: + qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') + for move in incoming_moves: + qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') + return qty + + def _get_outgoing_incoming_moves(self): + outgoing_moves = self.env['stock.move'] + incoming_moves = self.env['stock.move'] + + for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id): + if move.location_dest_id.usage == "customer": + if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund): + outgoing_moves |= move + elif move.location_dest_id.usage != "customer" and move.to_refund: + incoming_moves |= move + + return outgoing_moves, incoming_moves + + def _get_procurement_group(self): + return self.order_id.procurement_group_id + + def _prepare_procurement_group_vals(self): + return { + 'name': self.order_id.name, + 'move_type': self.order_id.picking_policy, + 'sale_id': self.order_id.id, + 'partner_id': self.order_id.partner_shipping_id.id, + } + + def _action_launch_stock_rule(self, previous_product_uom_qty=False): + """ + Launch procurement group run method with required/custom fields genrated by a + sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture' + depending on the sale order line product rule. + """ + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + procurements = [] + for line in self: + line = line.with_company(line.company_id) + if line.state != 'sale' or not line.product_id.type in ('consu','product'): + continue + qty = line._get_qty_procurement(previous_product_uom_qty) + if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: + continue + + group_id = line._get_procurement_group() + if not group_id: + group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals()) + line.order_id.procurement_group_id = group_id + else: + # In case the procurement group is already created and the order was + # cancelled, we need to update certain values of the group. + updated_vals = {} + if group_id.partner_id != line.order_id.partner_shipping_id: + updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id}) + if group_id.move_type != line.order_id.picking_policy: + updated_vals.update({'move_type': line.order_id.picking_policy}) + if updated_vals: + group_id.write(updated_vals) + + values = line._prepare_procurement_values(group_id=group_id) + product_qty = line.product_uom_qty - qty + + line_uom = line.product_uom + quant_uom = line.product_id.uom_id + product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom) + procurements.append(self.env['procurement.group'].Procurement( + line.product_id, product_qty, procurement_uom, + line.order_id.partner_shipping_id.property_stock_customer, + line.name, line.order_id.name, line.order_id.company_id, values)) + if procurements: + self.env['procurement.group'].run(procurements) + return True + + def _check_package(self): + default_uom = self.product_id.uom_id + pack = self.product_packaging + qty = self.product_uom_qty + q = default_uom._compute_quantity(pack.qty, self.product_uom) + # We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity + # per package might be a float, leading to incorrect results. For example: + # 8 % 1.6 = 1.5999999999999996 + # 5.4 % 1.8 = 2.220446049250313e-16 + if ( + qty + and q + and float_compare( + qty / q, float_round(qty / q, precision_rounding=1.0), precision_rounding=0.001 + ) + != 0 + ): + newqty = qty - (qty % q) + q + return { + 'warning': { + 'title': _('Warning'), + 'message': _( + "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.", + pack_size=pack.qty, + pack_name=default_uom.name, + quantity=newqty, + unit=self.product_uom.name + ), + }, + } + return {} + + def _update_line_quantity(self, values): + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu']) + if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1: + raise UserError(_('You cannot decrease the ordered quantity below the delivered quantity.\n' + 'Create a return first.')) + super(SaleOrderLine, self)._update_line_quantity(values) diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py new file mode 100644 index 00000000..5450852a --- /dev/null +++ b/addons/sale_stock/models/stock.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict + +from odoo import api, fields, models, _ + + +class StockLocationRoute(models.Model): + _inherit = "stock.location.route" + sale_selectable = fields.Boolean("Selectable on Sales Order Line") + + +class StockMove(models.Model): + _inherit = "stock.move" + sale_line_id = fields.Many2one('sale.order.line', 'Sale Line', index=True) + + @api.model + def _prepare_merge_moves_distinct_fields(self): + distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields() + distinct_fields.append('sale_line_id') + return distinct_fields + + @api.model + def _prepare_merge_move_sort_method(self, move): + move.ensure_one() + keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move) + keys_sorted.append(move.sale_line_id.id) + return keys_sorted + + def _get_related_invoices(self): + """ Overridden from stock_account to return the customer invoices + related to this stock move. + """ + rslt = super(StockMove, self)._get_related_invoices() + invoices = self.mapped('picking_id.sale_id.invoice_ids').filtered(lambda x: x.state == 'posted') + rslt += invoices + #rslt += invoices.mapped('reverse_entry_ids') + return rslt + + def _get_source_document(self): + res = super()._get_source_document() + return self.sale_line_id.order_id or res + + def _assign_picking_post_process(self, new=False): + super(StockMove, self)._assign_picking_post_process(new=new) + if new: + picking_id = self.mapped('picking_id') + sale_order_ids = self.mapped('sale_line_id.order_id') + for sale_order_id in sale_order_ids: + picking_id.message_post_with_view( + 'mail.message_origin_link', + values={'self': picking_id, 'origin': sale_order_id}, + subtype_id=self.env.ref('mail.mt_note').id) + + +class ProcurementGroup(models.Model): + _inherit = 'procurement.group' + + sale_id = fields.Many2one('sale.order', 'Sale Order') + + +class StockRule(models.Model): + _inherit = 'stock.rule' + + def _get_custom_move_fields(self): + fields = super(StockRule, self)._get_custom_move_fields() + fields += ['sale_line_id', 'partner_id'] + return fields + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + sale_id = fields.Many2one(related="group_id.sale_id", string="Sales Order", store=True, readonly=False) + + def _action_done(self): + res = super()._action_done() + sale_order_lines_vals = [] + for move in self.move_lines: + sale_order = move.picking_id.sale_id + # Creates new SO line only when pickings linked to a sale order and + # for moves with qty. done and not already linked to a SO line. + if not sale_order or move.location_dest_id.usage != 'customer' or move.sale_line_id or not move.quantity_done: + continue + product = move.product_id + so_line_vals = { + 'move_ids': [(4, move.id, 0)], + 'name': product.display_name, + 'order_id': sale_order.id, + 'product_id': product.id, + 'product_uom_qty': 0, + 'qty_delivered': move.quantity_done, + } + if product.invoice_policy == 'delivery': + # Check if there is already a SO line for this product to get + # back its unit price (in case it was manually updated). + so_line = sale_order.order_line.filtered(lambda sol: sol.product_id == product) + if so_line: + so_line_vals['price_unit'] = so_line[0].price_unit + elif product.invoice_policy == 'order': + # No unit price if the product is invoiced on the ordered qty. + so_line_vals['price_unit'] = 0 + sale_order_lines_vals.append(so_line_vals) + + if sale_order_lines_vals: + self.env['sale.order.line'].create(sale_order_lines_vals) + return res + + def _log_less_quantities_than_expected(self, moves): + """ Log an activity on sale order that are linked to moves. The + note summarize the real proccessed quantity and promote a + manual action. + + :param dict moves: a dict with a move as key and tuple with + new and old quantity as value. eg: {move_1 : (4, 5)} + """ + + def _keys_in_sorted(sale_line): + """ sort by order_id and the sale_person on the order """ + return (sale_line.order_id.id, sale_line.order_id.user_id.id) + + def _keys_in_groupby(sale_line): + """ group by order_id and the sale_person on the order """ + return (sale_line.order_id, sale_line.order_id.user_id) + + def _render_note_exception_quantity(moves_information): + """ Generate a note with the picking on which the action + occurred and a summary on impacted quantity that are + related to the sale order where the note will be logged. + + :param moves_information dict: + {'move_id': ['sale_order_line_id', (new_qty, old_qty)], ..} + + :return: an html string with all the information encoded. + :rtype: str + """ + origin_moves = self.env['stock.move'].browse([move.id for move_orig in moves_information.values() for move in move_orig[0]]) + origin_picking = origin_moves.mapped('picking_id') + values = { + 'origin_moves': origin_moves, + 'origin_picking': origin_picking, + 'moves_information': moves_information.values(), + } + return self.env.ref('sale_stock.exception_on_picking')._render(values=values) + + documents = self._log_activity_get_documents(moves, 'sale_line_id', 'DOWN', _keys_in_sorted, _keys_in_groupby) + self._log_activity(_render_note_exception_quantity, documents) + + return super(StockPicking, self)._log_less_quantities_than_expected(moves) + +class ProductionLot(models.Model): + _inherit = 'stock.production.lot' + + sale_order_ids = fields.Many2many('sale.order', string="Sales Orders", compute='_compute_sale_order_ids') + sale_order_count = fields.Integer('Sale order count', compute='_compute_sale_order_ids') + + @api.depends('name') + def _compute_sale_order_ids(self): + sale_orders = defaultdict(lambda: self.env['sale.order']) + for move_line in self.env['stock.move.line'].search([('lot_id', 'in', self.ids), ('state', '=', 'done')]): + move = move_line.move_id + if move.picking_id.location_dest_id.usage == 'customer' and move.sale_line_id.order_id: + sale_orders[move_line.lot_id.id] |= move.sale_line_id.order_id + for lot in self: + lot.sale_order_ids = sale_orders[lot.id] + lot.sale_order_count = len(lot.sale_order_ids) + + def action_view_so(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders") + action['domain'] = [('id', 'in', self.mapped('sale_order_ids.id'))] + action['context'] = dict(self._context, create=False) + return action |
