Odoo18-Base/addons/purchase/models/purchase_bill_line_match.py

200 lines
9.0 KiB
Python
Raw Permalink Normal View History

2025-01-06 10:57:38 +07:00
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools import SQL
from odoo.exceptions import UserError
class PurchaseBillMatch(models.Model):
_name = "purchase.bill.line.match"
_description = "Purchase Line and Vendor Bill line matching view"
_auto = False
_order = 'product_id, aml_id, pol_id'
pol_id = fields.Many2one(comodel_name='purchase.order.line')
aml_id = fields.Many2one(comodel_name='account.move.line')
company_id = fields.Many2one(comodel_name='res.company')
partner_id = fields.Many2one(comodel_name='res.partner')
product_id = fields.Many2one(comodel_name='product.product')
line_qty = fields.Float()
line_uom_id = fields.Many2one(comodel_name='uom.uom')
qty_invoiced = fields.Float()
purchase_order_id = fields.Many2one(comodel_name='purchase.order')
account_move_id = fields.Many2one(comodel_name='account.move')
line_amount_untaxed = fields.Monetary()
currency_id = fields.Many2one(comodel_name='res.currency')
state = fields.Char()
product_uom_id = fields.Many2one(comodel_name='uom.uom', related='product_id.uom_id')
product_uom_qty = fields.Float(compute='_compute_product_uom_qty', inverse='_inverse_product_uom_qty', readonly=False)
product_uom_price = fields.Float(compute='_compute_product_uom_price', inverse='_inverse_product_uom_price', readonly=False)
billed_amount_untaxed = fields.Monetary(compute='_compute_amount_untaxed_fields', currency_field='currency_id')
purchase_amount_untaxed = fields.Monetary(compute='_compute_amount_untaxed_fields', currency_field='currency_id')
reference = fields.Char(compute='_compute_reference')
@api.onchange('product_uom_price')
def _inverse_product_uom_price(self):
for line in self:
if line.aml_id:
line.aml_id.price_unit = line.product_uom_price
else:
line.pol_id.price_unit = line.product_uom_price
@api.onchange('product_uom_qty')
def _inverse_product_uom_qty(self):
for line in self:
if line.aml_id:
line.aml_id.quantity = line.product_uom_qty
else:
# on POL, setting product_qty will recompute price_unit to have the old value
# this prevents the price to revert by saving the previous price and re-setting them again
previous_price_unit = line.pol_id.price_unit
line.pol_id.product_qty = line.product_uom_qty
line.pol_id.price_unit = previous_price_unit
def _compute_amount_untaxed_fields(self):
for line in self:
line.billed_amount_untaxed = line.line_amount_untaxed if line.account_move_id else False
line.purchase_amount_untaxed = line.line_amount_untaxed if line.purchase_order_id else False
def _compute_reference(self):
for line in self:
line.reference = line.purchase_order_id.display_name or line.account_move_id.display_name
def _compute_display_name(self):
for line in self:
line.display_name = line.product_id.display_name or line.aml_id.name or line.pol_id.name
def _compute_product_uom_qty(self):
for line in self:
line.product_uom_qty = line.line_uom_id._compute_quantity(line.line_qty, line.product_uom_id)
@api.depends('aml_id.price_unit', 'pol_id.price_unit')
def _compute_product_uom_price(self):
for line in self:
line.product_uom_price = line.aml_id.price_unit if line.aml_id else line.pol_id.price_unit
@api.model
def _select_po_line(self):
return SQL("""
SELECT pol.id,
pol.id as pol_id,
NULL as aml_id,
pol.company_id as company_id,
pol.partner_id as partner_id,
pol.product_id as product_id,
pol.product_qty as line_qty,
pol.product_uom as line_uom_id,
pol.qty_invoiced as qty_invoiced,
po.id as purchase_order_id,
NULL as account_move_id,
pol.price_subtotal as line_amount_untaxed,
pol.currency_id as currency_id,
po.state as state
FROM purchase_order_line pol
LEFT JOIN purchase_order po ON pol.order_id = po.id
WHERE pol.state in ('purchase', 'done')
AND pol.product_qty > pol.qty_invoiced
OR ((pol.display_type = '' OR pol.display_type IS NULL) AND pol.is_downpayment AND pol.qty_invoiced > 0)
""")
@api.model
def _select_am_line(self):
return SQL("""
SELECT -aml.id,
NULL as pol_id,
aml.id as aml_id,
aml.company_id as company_id,
aml.partner_id as partner_id,
aml.product_id as product_id,
aml.quantity as line_qty,
aml.product_uom_id as line_uom_id,
NULL as qty_invoiced,
NULL as purchase_order_id,
am.id as account_move_id,
aml.amount_currency as line_amount_untaxed,
aml.currency_id as currency_id,
aml.parent_state as state
FROM account_move_line aml
LEFT JOIN account_move am on aml.move_id = am.id
WHERE aml.display_type = 'product'
AND am.move_type in ('in_invoice', 'in_refund')
AND aml.parent_state in ('draft', 'posted')
AND aml.purchase_line_id IS NULL
""")
@property
def _table_query(self):
return SQL("%s UNION ALL %s", self._select_po_line(), self._select_am_line())
def action_open_line(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move' if self.account_move_id else 'purchase.order',
'view_mode': 'form',
'res_id': self.account_move_id.id if self.account_move_id else self.purchase_order_id.id,
}
@api.model
def _action_create_bill_from_po_lines(self, partner, po_lines):
""" Create a new vendor bill with the selected PO lines and returns an action to open it """
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': partner.id,
})
bill._add_purchase_order_lines(po_lines)
return bill._get_records_action()
def action_match_lines(self):
if not self.pol_id: # we need POL(s) to either match or create bill
raise UserError(_("You must select at least one Purchase Order line to match or create bill."))
if not self.aml_id: # select POL(s) without AML -> create a draft bill with the POL(s)
return self._action_create_bill_from_po_lines(self.partner_id, self.pol_id)
if len(self.aml_id.move_id) > 1: # for purchase matching, disallow matching multiple bills at the same time
raise UserError(_("You can't select lines from multiple Vendor Bill to do the matching."))
pol_by_product = self.pol_id.grouped('product_id')
aml_by_product = self.aml_id.grouped('product_id')
residual_purchase_order_lines = self.pol_id
residual_account_move_lines = self.aml_id
residual_bill = self.aml_id.move_id
# Match all matchable POL-AML lines and remove them from the residual group
for product, po_line in pol_by_product.items():
po_line = po_line[0] # in case of multiple POL with same product, only match the first one
matching_bill_lines = aml_by_product.get(product)
if matching_bill_lines:
matching_bill_lines.purchase_line_id = po_line.id
residual_purchase_order_lines -= po_line
residual_account_move_lines -= matching_bill_lines
# Delete all unmatched selected AML
if residual_account_move_lines:
residual_account_move_lines.unlink()
# Add all remaining POL to the residual bill
residual_bill._add_purchase_order_lines(residual_purchase_order_lines)
def action_add_to_po(self):
if not self or not self.aml_id:
raise UserError(_("Select Vendor Bill lines to add to a Purchase Order"))
context = {
'default_partner_id': self.partner_id.id,
'dialog_size': 'medium',
'has_products': bool(self.aml_id.product_id),
}
if len(self.purchase_order_id) > 1:
raise UserError(_("Vendor Bill lines can only be added to one Purchase Order."))
elif self.purchase_order_id:
context['default_purchase_order_id'] = self.purchase_order_id.id
return {
'type': 'ir.actions.act_window',
'name': _("Add to Purchase Order"),
'res_model': 'bill.to.po.wizard',
'target': 'new',
'views': [(self.env.ref('purchase.bill_to_po_wizard_form').id, 'form')],
'context': context,
}