# Copyright (C) 2019-Today: Odoo Community Association (OCA) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, api, fields, models from odoo.exceptions import UserError JOURNAL_TYPE_MAP = { ("outgoing", "customer"): ["sale"], ("outgoing", "supplier"): ["purchase"], ("outgoing", "transit"): ["sale", "purchase"], ("incoming", "supplier"): ["purchase"], ("incoming", "customer"): ["sale"], ("incoming", "transit"): ["purchase", "sale"], } INVOICE_TYPE_MAP = { # Picking Type Code | Local Origin Usage | Local Dest Usage ("outgoing", "internal", "customer"): "out_invoice", ("incoming", "customer", "internal"): "out_refund", ("incoming", "customer", "customer"): "out_refund", ("incoming", "supplier", "internal"): "in_invoice", ("incoming", "supplier", "customer"): "in_invoice", ("outgoing", "internal", "supplier"): "in_refund", ("outgoing", "internal", "internal"): "in_refund", ("incoming", "transit", "internal"): "in_invoice", ("outgoing", "transit", "supplier"): "in_refund", ("outgoing", "transit", "customer"): "out_invoice", } class StockInvoiceOnshipping(models.TransientModel): _name = "stock.invoice.onshipping" _description = "Stock Invoice Onshipping" @api.model def _get_journal_type(self): active_ids = self.env.context.get("active_ids", []) if active_ids: active_ids = active_ids[0] pick_obj = self.env["stock.picking"] picking = pick_obj.browse(active_ids) if not picking or not picking.move_ids: return "sale" pick_type_code = picking.picking_type_id.code line = fields.first(picking.move_ids) if pick_type_code == "incoming": usage = line.location_id.usage else: usage = line.location_dest_id.usage return JOURNAL_TYPE_MAP.get((pick_type_code, usage), ["sale"])[0] journal_type = fields.Selection( selection=[ ("purchase", "Create Supplier Invoice"), ("sale", "Create Customer Invoice"), ], default=_get_journal_type, readonly=True, ) group = fields.Selection( selection=[ ("picking", "Picking"), ("partner", "Partner"), ("partner_product", "Partner/Product"), ], default="picking", help="Group pickings/moves to create invoice(s):\n" "Picking: One invoice per picking;\n" "Partner: One invoice for each picking's partner;\n" "Partner/Product: One invoice per picking's partner and group " "product into a single invoice line.", required=True, ) invoice_date = fields.Date() sale_journal = fields.Many2one( comodel_name="account.journal", domain="[('type', '=', 'sale')]", default=lambda self: self._default_journal("sale"), ondelete="cascade", ) purchase_journal = fields.Many2one( comodel_name="account.journal", domain="[('type', '=', 'purchase')]", default=lambda self: self._default_journal("purchase"), ondelete="cascade", ) show_sale_journal = fields.Boolean() show_purchase_journal = fields.Boolean() @api.model def default_get(self, fields_list): """ Inherit to add default invoice_date :param fields_list: list of str :return: dict """ result = super().default_get(fields_list) result.update({"invoice_date": fields.Date.today()}) return result @api.onchange("group") def onchange_group(self): self.ensure_one() ( sale_pickings, sale_refund_pickings, purchase_pickings, purchase_refund_pickings, ) = self.get_split_pickings() self.show_sale_journal = bool(sale_pickings) self.show_purchase_journal = bool(purchase_pickings) def get_partner_sum(self, pickings, partner, inv_type, picking_type, usage): pickings = pickings.filtered( lambda x: x.picking_type_id.code == picking_type and x.partner_id == partner ) lines = pickings.mapped("move_ids") if picking_type == "outgoing": moves = lines.filtered(lambda x: x.location_dest_id.usage == usage) else: moves = lines.filtered(lambda x: x.location_id.usage == usage) total = sum( ( m._get_price_unit_invoice(inv_type, m.picking_id.partner_id) * m.product_uom_qty ) for m in moves ) return total, moves.mapped("picking_id") def get_split_pickings(self): self.ensure_one() picking_obj = self.env["stock.picking"] pickings = picking_obj.browse(self.env.context.get("active_ids", [])) if self.group != "picking": return self.get_split_pickings_grouped(pickings) return self.get_split_pickings_nogrouped(pickings) def get_split_pickings_grouped(self, pickings): sale_pickings = self.env["stock.picking"].browse() sale_refund_pickings = self.env["stock.picking"].browse() purchase_pickings = self.env["stock.picking"].browse() purchase_refund_pickings = self.env["stock.picking"].browse() for partner in pickings.mapped("partner_id"): so_sum, so_pickings = self.get_partner_sum( pickings, partner, "out_invoice", "outgoing", "customer" ) si_sum, si_pickings = self.get_partner_sum( pickings, partner, "out_invoice", "incoming", "customer" ) if (so_sum - si_sum) >= 0: sale_pickings |= so_pickings | si_pickings else: sale_refund_pickings |= so_pickings | si_pickings pi_sum, pi_pickings = self.get_partner_sum( pickings, partner, "in_invoice", "incoming", "supplier" ) po_sum, po_pickings = self.get_partner_sum( pickings, partner, "in_invoice", "outgoing", "supplier" ) if (pi_sum - po_sum) >= 0: purchase_pickings |= pi_pickings | po_pickings else: purchase_refund_pickings |= pi_pickings | po_pickings return ( sale_pickings, sale_refund_pickings, purchase_pickings, purchase_refund_pickings, ) def get_split_pickings_nogrouped(self, pickings): first = fields.first sale_pickings = pickings.filtered( lambda x: x.picking_type_id.code == "outgoing" and first(x.move_ids).location_dest_id.usage == "customer" ) sale_refund_pickings = pickings.filtered( lambda x: x.picking_type_id.code == "incoming" and first(x.move_ids).location_id.usage == "customer" ) purchase_pickings = pickings.filtered( lambda x: x.picking_type_id.code == "incoming" and first(x.move_ids).location_id.usage == "supplier" ) purchase_refund_pickings = pickings.filtered( lambda x: x.picking_type_id.code == "outgoing" and first(x.move_ids).location_dest_id.usage == "supplier" ) return ( sale_pickings, sale_refund_pickings, purchase_pickings, purchase_refund_pickings, ) @api.model def _default_journal(self, journal_type): """ Get the default journal based on the given type :param journal_type: str :return: account.journal recordset """ default_journal = self.env["account.journal"].search( [ ("type", "=", journal_type), ("company_id", "=", self.env.company.id), ], limit=1, ) return default_journal def action_generate(self): """ Launch the invoice generation :return: """ self.ensure_one() invoices = self._action_generate_invoices() if not invoices: raise UserError(_("No invoice created!")) # Update the state on pickings related to new invoices only self._update_picking_invoice_status(invoices.mapped("picking_ids")) inv_type = self._get_invoice_type() if inv_type in ["out_invoice", "out_refund"]: xmlid = "account.action_move_out_invoice_type" else: xmlid = "account.action_move_in_invoice_type" action_dict = self.env["ir.actions.act_window"]._for_xml_id(xmlid) if len(invoices) > 1: action_dict["domain"] = [("id", "in", invoices.ids)] elif len(invoices) == 1: if inv_type in ["out_invoice", "out_refund"]: form_view = [(self.env.ref("account.view_move_form").id, "form")] else: form_view = [(self.env.ref("account.view_move_form").id, "form")] if "views" in action_dict: action_dict["views"] = form_view + [ (state, view) for state, view in action_dict["views"] if view != "form" ] else: action_dict["views"] = form_view action_dict["res_id"] = invoices.ids[0] return action_dict def _load_pickings(self): """ Load pickings from context :return: stock.picking recordset """ picking_obj = self.env["stock.picking"] active_ids = self.env.context.get("active_ids", []) pickings = picking_obj.browse(active_ids) pickings = pickings.filtered(lambda p: p.invoice_state == "2binvoiced") return pickings def _get_journal(self): """ Get the journal depending on the journal_type :return: account.journal recordset """ self.ensure_one() journal_field = f"{self.journal_type}_journal" journal = self[journal_field] return journal def _get_invoice_type(self): """ Get the invoice type :return: str """ self.ensure_one() active_ids = self.env.context.get("active_ids", []) if active_ids: active_ids = active_ids[0] picking = self.env["stock.picking"].browse(active_ids) inv_type = ( INVOICE_TYPE_MAP.get( ( picking.picking_type_code, picking.location_id.usage, picking.location_dest_id.usage, ) ) or "out_invoice" ) return inv_type @api.model def _get_picking_key(self, picking): """ Get the key for the given picking. By default, it's based on the invoice partner and the picking_type_id of the picking :param picking: stock.picking recordset :return: key (tuple,...) """ key = (picking,) if self.group in ["partner", "partner_product"]: # Pickings with same Partner to create Invoice but the # Partner to Shipping is different should not be grouping. key = ( picking._get_partner_to_invoice(), picking.picking_type_id, picking.partner_id, ) return key def _group_pickings(self, pickings): """ Group given picking :param pickings: :return: list of stock.picking recordset """ grouped_picking = {} pickings = pickings.filtered(lambda p: p.invoice_state == "2binvoiced") for picking in pickings: key = self._get_picking_key(picking) picks_grouped = grouped_picking.get(key, self.env["stock.picking"].browse()) picks_grouped |= picking grouped_picking.update({key: picks_grouped}) return grouped_picking.values() def _simulate_invoice_onchange(self, values): """ Simulate onchange for invoice :param values: dict :return: dict """ invoice = self.env["account.move"].new(values.copy()) invoice._onchange_partner_id() invoice._inverse_company_id() invoice._inverse_currency_id() invoice._onchange_date() new_values = invoice._convert_to_write(invoice._cache) # Ensure basic values are not updated values.update(new_values) return invoice, values def _build_invoice_values_from_pickings(self, pickings): """ Build dict to create a new invoice from given pickings :param pickings: stock.picking recordset :return: dict """ picking = fields.first(pickings) partner_id = picking._get_partner_to_invoice() partner = self.env["res.partner"].browse(partner_id) inv_type = self._get_invoice_type() currency = self.env.company.currency_id if partner: code = picking.picking_type_id.code if partner.property_product_pricelist and code == "outgoing": currency = partner.property_product_pricelist.currency_id journal = self._get_journal() invoice_obj = self.env["account.move"] values = invoice_obj.default_get(invoice_obj.fields_get().keys()) values.update( { "invoice_origin": ", ".join(pickings.mapped("name")), "partner_id": partner_id, "move_type": inv_type, "currency_id": currency.id, "journal_id": journal.id, } ) invoice, values = self._simulate_invoice_onchange(values) return invoice, values def _get_move_key(self, move): """ Get the key based on the given move :param move: stock.move recordset :return: (key,) """ key = (move,) if self.group == "partner_product": key = (move.product_id,) return key def _group_moves(self, moves): """ Possibility to group moves (to create 1 invoice line with many moves) :param moves: stock.move recordset :return: list of stock.move recordset """ grouped_moves = {} moves = moves.filtered(lambda m: m.invoice_state == "2binvoiced") for move in moves: key = self._get_move_key(move) move_grouped = grouped_moves.get(key, self.env["stock.move"].browse()) move_grouped |= move grouped_moves.update({key: move_grouped}) return grouped_moves.values() def _simulate_invoice_line_onchange(self, values, price_unit=None): """ Simulate onchange for invoice line :param values: dict :return: dict """ line = self.env["account.move.line"].new(values.copy()) line._inverse_partner_id() line._inverse_product_id() line._inverse_account_id() line._inverse_amount_currency() new_values = line._convert_to_write(line._cache) if price_unit: new_values["price_unit"] = price_unit # Ensure basic values are not updated values.update(new_values) return values def _get_invoice_line_values(self, moves, invoice_values, invoice): """ Create invoice line values from given moves :param moves: stock.move :param invoice: account.move :return: dict """ move = fields.first(moves) product = move.product_id partner_id = self.env["res.partner"].browse(invoice_values["partner_id"]) inv_type = invoice_values["move_type"] quantity = 0 move_line_ids = [] for move in moves: qty = move.product_uom_qty loc = move.location_id loc_dst = move.location_dest_id # Better to understand with IF/ELIF than many OR if inv_type == "out_invoice" and loc.usage == "customer": qty *= -1 elif inv_type == "out_refund" and loc_dst.usage == "customer": qty *= -1 elif inv_type == "in_invoice" and loc_dst.usage == "supplier": qty *= -1 elif inv_type == "in_refund" and loc.usage == "supplier": qty *= -1 quantity += qty move_line_ids.append((4, move.id)) price = moves._get_price_unit_invoice(inv_type, partner_id, quantity) line_obj = self.env["account.move.line"] values = line_obj.default_get(line_obj.fields_get().keys()) values.update( { "name": move.name, "product_id": product.id, "quantity": quantity, "price_unit": price, "move_line_ids": move_line_ids, "move_id": invoice.id, } ) values = self._simulate_invoice_line_onchange(values, price_unit=price) return values def _update_picking_invoice_status(self, pickings): """ Update invoice_state on pickings :param pickings: stock.picking recordset :return: stock.picking recordset """ return pickings.set_as_invoiced() def ungroup_moves(self, grouped_moves_list): """Ungroup your moves, split them again, grouping by fiscal position, max itens per invoice and etc :param grouped_moves_list: :return: list of grouped moves list """ return [grouped_moves_list] def _create_invoice(self, invoice_values): """Override this method if you need to change any values of the invoice and the lines before the invoice creation :param invoice_values: dict with the invoice and its lines :return: invoice """ return self.env["account.move"].create(invoice_values) def _action_generate_invoices(self): """ Action to generate invoices based on pickings :return: account.move recordset """ pickings = self._load_pickings() company = pickings.mapped("company_id") if company and company != self.env.company: raise UserError(_("All pickings are not related to your company!")) pick_list = self._group_pickings(pickings) invoices = self.env["account.move"].browse() for pickings in pick_list: moves = pickings.mapped("move_ids") grouped_moves_list = self._group_moves(moves) parts = self.ungroup_moves(grouped_moves_list) for moves_list in parts: invoice, invoice_values = self._build_invoice_values_from_pickings( pickings ) lines = [(5, 0, {})] line_values = False for moves in moves_list: line_values = self._get_invoice_line_values( moves, invoice_values, invoice ) if line_values: lines.append((0, 0, line_values)) if line_values: # Only create the invoice if it has lines invoice_values["invoice_line_ids"] = lines # Necessary inform line_ids with empty list to avoid error # addons/account/models/account_move.py", line 2251, # in _sanitize_vals # assert command not in (Command.SET, Command.CLEAR) invoice_values["line_ids"] = [] invoice_values["invoice_date"] = self.invoice_date invoice = self._create_invoice(invoice_values) invoice._compute_amount() invoices |= invoice return invoices