mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-22 16:51:58 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -1,9 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import difflib
|
||||
import logging
|
||||
import time
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, Command, _
|
||||
from odoo.tools import OrderedSet
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -13,22 +16,19 @@ TOLERANCE = 0.02 # tolerance applied to the total when searching for a matching
|
|||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
purchase_vendor_bill_id = fields.Many2one('purchase.bill.union', store=False, readonly=True,
|
||||
states={'draft': [('readonly', False)]},
|
||||
purchase_vendor_bill_id = fields.Many2one('purchase.bill.union', store=False, readonly=False,
|
||||
string='Auto-complete',
|
||||
help="Auto-complete from a past bill / purchase order.")
|
||||
purchase_id = fields.Many2one('purchase.order', store=False, readonly=True,
|
||||
states={'draft': [('readonly', False)]},
|
||||
help="Auto-complete from a previous bill, refund, or purchase order.")
|
||||
purchase_id = fields.Many2one('purchase.order', store=False, readonly=False,
|
||||
string='Purchase Order',
|
||||
help="Auto-complete from a past purchase order.")
|
||||
purchase_order_count = fields.Integer(compute="_compute_origin_po_count", string='Purchase Order Count')
|
||||
|
||||
def _get_invoice_reference(self):
|
||||
self.ensure_one()
|
||||
vendor_refs = [ref for ref in set(self.invoice_line_ids.mapped('purchase_line_id.order_id.partner_ref')) if ref]
|
||||
if self.ref:
|
||||
return [ref for ref in self.ref.split(', ') if ref and ref not in vendor_refs] + vendor_refs
|
||||
return vendor_refs
|
||||
purchase_order_name = fields.Char(compute='_compute_purchase_order_name')
|
||||
is_purchase_matched = fields.Boolean(compute='_compute_is_purchase_matched') # 0: PO not required or partially linked. 1: All lines linked
|
||||
purchase_warning_text = fields.Text(
|
||||
"Purchase Warning",
|
||||
help="Internal warning for the partner or the products as set by the user.",
|
||||
compute='_compute_purchase_warning_text')
|
||||
|
||||
@api.onchange('purchase_vendor_bill_id', 'purchase_id')
|
||||
def _onchange_purchase_auto_complete(self):
|
||||
|
|
@ -52,9 +52,8 @@ class AccountMove(models.Model):
|
|||
|
||||
# Copy data from PO
|
||||
invoice_vals = self.purchase_id.with_company(self.purchase_id.company_id)._prepare_invoice()
|
||||
has_invoice_lines = bool(self.invoice_line_ids.filtered(lambda x: x.display_type not in ('line_note', 'line_section')))
|
||||
has_invoice_lines = bool(self.invoice_line_ids.filtered(lambda x: x.display_type not in ('line_section', 'line_subsection', 'line_note')))
|
||||
new_currency_id = self.currency_id if has_invoice_lines else invoice_vals.get('currency_id')
|
||||
del invoice_vals['ref'], invoice_vals['payment_reference']
|
||||
del invoice_vals['company_id'] # avoid recomputing the currency
|
||||
if self.move_type == invoice_vals['move_type']:
|
||||
del invoice_vals['move_type'] # no need to be updated if it's same value, to avoid recomputes
|
||||
|
|
@ -63,25 +62,15 @@ class AccountMove(models.Model):
|
|||
|
||||
# Copy purchase lines.
|
||||
po_lines = self.purchase_id.order_line - self.invoice_line_ids.mapped('purchase_line_id')
|
||||
for line in po_lines.filtered(lambda l: not l.display_type):
|
||||
self.invoice_line_ids += self.env['account.move.line'].new(
|
||||
line._prepare_account_move_line(self)
|
||||
)
|
||||
self._add_purchase_order_lines(po_lines)
|
||||
|
||||
# Compute invoice_origin.
|
||||
origins = set(self.invoice_line_ids.mapped('purchase_line_id.order_id.name'))
|
||||
self.invoice_origin = ','.join(list(origins))
|
||||
|
||||
# Compute ref.
|
||||
refs = self._get_invoice_reference()
|
||||
self.ref = ', '.join(refs)
|
||||
|
||||
# Compute payment_reference.
|
||||
if not self.payment_reference:
|
||||
if len(refs) == 1:
|
||||
self.payment_reference = refs[0]
|
||||
elif len(refs) > 1:
|
||||
self.payment_reference = refs[-1]
|
||||
# Copy company_id (only changes if the id is of a child company (branch))
|
||||
if self.company_id != self.purchase_id.company_id:
|
||||
self.company_id = self.purchase_id.company_id
|
||||
|
||||
self.purchase_id = False
|
||||
|
||||
|
|
@ -98,8 +87,8 @@ class AccountMove(models.Model):
|
|||
if self.partner_id and self.move_type in ['in_invoice', 'in_refund'] and self.currency_id != currency_id:
|
||||
if not self.env.context.get('default_journal_id'):
|
||||
journal_domain = [
|
||||
*self.env['account.journal']._check_company_domain(self.company_id),
|
||||
('type', '=', 'purchase'),
|
||||
('company_id', '=', self.company_id.id),
|
||||
('currency_id', '=', currency_id.id),
|
||||
]
|
||||
default_journal_id = self.env['account.journal'].search(journal_domain, limit=1)
|
||||
|
|
@ -110,11 +99,62 @@ class AccountMove(models.Model):
|
|||
|
||||
return res
|
||||
|
||||
@api.depends('line_ids.purchase_line_id')
|
||||
def _compute_is_purchase_matched(self):
|
||||
for move in self:
|
||||
if any(il.display_type == 'product' and not bool(il.purchase_line_id) for il in move.invoice_line_ids):
|
||||
move.is_purchase_matched = False
|
||||
continue
|
||||
move.is_purchase_matched = True
|
||||
|
||||
@api.depends('line_ids.purchase_line_id')
|
||||
def _compute_origin_po_count(self):
|
||||
for move in self:
|
||||
move.purchase_order_count = len(move.line_ids.purchase_line_id.order_id)
|
||||
|
||||
@api.depends('purchase_order_count')
|
||||
def _compute_purchase_order_name(self):
|
||||
for move in self:
|
||||
if move.purchase_order_count == 1:
|
||||
move.purchase_order_name = move.invoice_line_ids.purchase_order_id.display_name
|
||||
else:
|
||||
move.purchase_order_name = False
|
||||
|
||||
@api.depends('partner_id.name', 'partner_id.purchase_warn_msg', 'invoice_line_ids.product_id.purchase_line_warn_msg', 'invoice_line_ids.product_id.display_name')
|
||||
def _compute_purchase_warning_text(self):
|
||||
if not self.env.user.has_group('purchase.group_warning_purchase'):
|
||||
self.purchase_warning_text = ''
|
||||
return
|
||||
for move in self:
|
||||
if move.move_type != 'in_invoice':
|
||||
move.purchase_warning_text = ''
|
||||
continue
|
||||
warnings = OrderedSet()
|
||||
if partner_msg := move.partner_id.purchase_warn_msg:
|
||||
warnings.add((move.partner_id.name or move.partner_id.display_name) + ' - ' + partner_msg)
|
||||
if partner_parent_msg := move.partner_id.parent_id.purchase_warn_msg:
|
||||
parent = move.partner_id.parent_id
|
||||
warnings.add((parent.name or parent.display_name) + ' - ' + partner_parent_msg)
|
||||
for product in move.invoice_line_ids.product_id:
|
||||
if product_msg := product.purchase_line_warn_msg:
|
||||
warnings.add(product.display_name + ' - ' + product_msg)
|
||||
move.purchase_warning_text = '\n'.join(warnings)
|
||||
|
||||
def action_purchase_matching(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Purchase Matching"),
|
||||
'res_model': 'purchase.bill.line.match',
|
||||
'domain': [
|
||||
('partner_id', 'in', (self.partner_id | self.partner_id.commercial_partner_id).ids),
|
||||
('company_id', 'in', self.env.companies.ids),
|
||||
('company_id', 'child_of', self.company_id.ids),
|
||||
('account_move_id', 'in', [self.id, False]),
|
||||
],
|
||||
'views': [(self.env.ref('purchase.purchase_bill_line_match_tree').id, 'list')],
|
||||
}
|
||||
|
||||
def action_view_source_purchase_orders(self):
|
||||
self.ensure_one()
|
||||
source_orders = self.line_ids.purchase_line_id.order_id
|
||||
|
|
@ -139,7 +179,7 @@ class AccountMove(models.Model):
|
|||
if not purchases:
|
||||
continue
|
||||
refs = [purchase._get_html_link() for purchase in purchases]
|
||||
message = _("This vendor bill has been created from: %s") % ','.join(refs)
|
||||
message = _("This vendor bill has been created from: ") + Markup(',').join(refs)
|
||||
move.message_post(body=message)
|
||||
return moves
|
||||
|
||||
|
|
@ -154,133 +194,365 @@ class AccountMove(models.Model):
|
|||
diff_purchases = new_purchases - old_purchases[i]
|
||||
if diff_purchases:
|
||||
refs = [purchase._get_html_link() for purchase in diff_purchases]
|
||||
message = _("This vendor bill has been modified from: %s") % ','.join(refs)
|
||||
message = _("This vendor bill has been modified from: ") + Markup(',').join(refs)
|
||||
move.message_post(body=message)
|
||||
return res
|
||||
|
||||
def find_matching_subset_invoice_lines(self, invoice_lines, goal_total, timeout):
|
||||
""" The problem of finding the subset of `invoice_lines` which sums up to `goal_total` reduces to the 0-1 Knapsack problem.
|
||||
The dynamic programming approach to solve this problem is most of the time slower than this because identical sub-problems don't arise often enough.
|
||||
It returns the list of invoice lines which sums up to `goal_total` or an empty list if multiple or no solutions were found."""
|
||||
def _find_matching_subset_invoice_lines(lines, goal):
|
||||
def _add_purchase_order_lines(self, purchase_order_lines):
|
||||
""" Creates new invoice lines from purchase order lines """
|
||||
self.ensure_one()
|
||||
new_line_ids = self.env['account.move.line']
|
||||
|
||||
for po_line in purchase_order_lines:
|
||||
new_line_values = po_line._prepare_account_move_line(self)
|
||||
new_line_ids += self.env['account.move.line'].new(new_line_values)
|
||||
|
||||
self.invoice_line_ids += new_line_ids
|
||||
|
||||
def _find_matching_subset_po_lines(self, po_lines_with_amount, goal_total, timeout):
|
||||
"""Finds the purchase order lines adding up to the goal amount.
|
||||
|
||||
The problem of finding the subset of `po_lines_with_amount` which sums up to `goal_total` reduces to
|
||||
the 0-1 Knapsack problem. The dynamic programming approach to solve this problem is most of the time slower
|
||||
than this because identical sub-problems don't arise often enough. It returns the list of purchase order lines
|
||||
which sum up to `goal_total` or an empty list if multiple or no solutions were found.
|
||||
|
||||
:param po_lines_with_amount: a dict (str: float|recordset) containing:
|
||||
* line: an `purchase.order.line`
|
||||
* amount_to_invoice: the remaining amount to be invoiced of the line
|
||||
:param goal_total: the total amount to match with a subset of purchase order lines
|
||||
:param timeout: the max time the line matching algorithm can take before timing out
|
||||
:return: list of `purchase.order.line` whose remaining sum matches `goal_total`
|
||||
"""
|
||||
def find_matching_subset_po_lines(lines, goal):
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError
|
||||
solutions = []
|
||||
for i, line in enumerate(lines):
|
||||
if line['amount_to_invoice'] < goal - TOLERANCE:
|
||||
sub_solutions = _find_matching_subset_invoice_lines(lines[i + 1:], goal - line['amount_to_invoice'])
|
||||
solutions.extend((line, *solution) for solution in sub_solutions)
|
||||
# The amount to invoice of the current purchase order line is less than the amount we still need on
|
||||
# the vendor bill.
|
||||
# We try finding purchase order lines that match the remaining vendor bill amount minus the amount
|
||||
# to invoice of the current purchase order line. We only look in the purchase order lines that we
|
||||
# haven't passed yet.
|
||||
sub_solutions = find_matching_subset_po_lines(lines[i + 1:], goal - line['amount_to_invoice'])
|
||||
# We add all possible sub-solutions' purchase order lines in a tuple together with our current
|
||||
# purchase order line.
|
||||
solutions.extend((line['line'], *solution) for solution in sub_solutions)
|
||||
elif goal - TOLERANCE <= line['amount_to_invoice'] <= goal + TOLERANCE:
|
||||
solutions.append([line])
|
||||
# The amount to invoice of the current purchase order line matches the remaining vendor bill amount.
|
||||
# We add this purchase order line to our list of solutions.
|
||||
solutions.append([line['line']])
|
||||
if len(solutions) > 1:
|
||||
# More than 1 solution found, we can't know for sure which is the correct one, so we don't return any solution
|
||||
# More than one solution was found. We can't know for sure which is the correct one, so we don't
|
||||
# return any solution.
|
||||
return []
|
||||
return solutions
|
||||
start_time = time.time()
|
||||
try:
|
||||
subsets = _find_matching_subset_invoice_lines(sorted(invoice_lines, key=lambda line: line['amount_to_invoice'], reverse=True), goal_total)
|
||||
subsets = find_matching_subset_po_lines(
|
||||
sorted(po_lines_with_amount, key=lambda line: line['amount_to_invoice'], reverse=True),
|
||||
goal_total
|
||||
)
|
||||
return subsets[0] if subsets else []
|
||||
except TimeoutError:
|
||||
_logger.warning("Timed out during search of a matching subset of invoice lines")
|
||||
_logger.warning("Timed out during search of a matching subset of purchase order lines")
|
||||
return []
|
||||
|
||||
def _set_purchase_orders(self, purchase_orders, force_write=True):
|
||||
with self.env.cr.savepoint():
|
||||
with self._get_edi_creation() as move_form:
|
||||
if force_write and move_form.line_ids:
|
||||
move_form.invoice_line_ids = [Command.clear()]
|
||||
for purchase_order in purchase_orders:
|
||||
move_form.invoice_line_ids = [Command.create({
|
||||
'display_type': 'line_section',
|
||||
'name': _('From %s document', purchase_order.name)
|
||||
})]
|
||||
move_form.purchase_id = purchase_order
|
||||
move_form._onchange_purchase_auto_complete()
|
||||
def _find_matching_po_and_inv_lines(self, po_lines, inv_lines, timeout):
|
||||
"""Finds purchase order lines that match some of the invoice lines.
|
||||
|
||||
def _match_purchase_orders(self, po_references, partner_id, amount_total, timeout):
|
||||
""" Tries to match a purchase order given some bill arguments/hints.
|
||||
We try to find a purchase order line for every invoice line matching on the unit price and having at least
|
||||
the same quantity to invoice.
|
||||
|
||||
:param po_references: A list of potencial purchase order references/name.
|
||||
:param partner_id: The vendor id.
|
||||
:param amount_total: The vendor bill total.
|
||||
:param timeout: The timeout for subline search
|
||||
:return: A tuple containing:
|
||||
* a str which is the match method:
|
||||
'total_match': the invoice amount AND the partner or bill' reference match
|
||||
'subset_total_match': the reference AND a subset of line that match the bill amount total
|
||||
'po_match': only the reference match
|
||||
'no_match': no result found
|
||||
* recordset of matched 'purchase.order.line' (could come from more than one purchase.order)
|
||||
:param po_lines: list of purchase order lines that can be matched
|
||||
:param inv_lines: list of invoice lines to be matched
|
||||
:param timeout: how long this function can run before we consider it too long
|
||||
:return: a tuple (list, list) containing:
|
||||
* matched 'purchase.order.line'
|
||||
* tuple of purchase order line ids and their matched 'account.move.line'
|
||||
"""
|
||||
common_domain = [('company_id', '=', self.company_id.id), ('state', 'in', ('purchase', 'done')), ('invoice_status', 'in', ('to invoice', 'no'))]
|
||||
# Sort the invoice lines by unit price and quantity to speed up matching
|
||||
invoice_lines = sorted(inv_lines, key=lambda line: (line.price_unit, line.quantity), reverse=True)
|
||||
# Sort the purchase order lines by unit price and remaining quantity to speed up matching
|
||||
purchase_lines = sorted(
|
||||
po_lines,
|
||||
key=lambda line: (line.price_unit, line.product_qty - line.qty_invoiced),
|
||||
reverse=True
|
||||
)
|
||||
matched_po_lines = []
|
||||
matched_inv_lines = []
|
||||
try:
|
||||
start_time = time.time()
|
||||
for invoice_line in invoice_lines:
|
||||
# There are no purchase order lines left. We are done matching.
|
||||
if not purchase_lines:
|
||||
break
|
||||
# A dict of purchase lines mapping to a diff score for the name
|
||||
purchase_line_candidates = {}
|
||||
for purchase_line in purchase_lines:
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError
|
||||
|
||||
matching_pos = self.env['purchase.order']
|
||||
# The lists are sorted by unit price descendingly.
|
||||
# When the unit price of the purchase line is lower than the unit price of the invoice line,
|
||||
# we cannot get a match anymore.
|
||||
if purchase_line.price_unit < invoice_line.price_unit:
|
||||
break
|
||||
|
||||
if (invoice_line.price_unit == purchase_line.price_unit
|
||||
and invoice_line.quantity <= purchase_line.product_qty - purchase_line.qty_invoiced):
|
||||
# The current purchase line is a possible match for the current invoice line.
|
||||
# We calculate the name match ratio and continue with other possible matches.
|
||||
#
|
||||
# We could match on more fields coming from an EDI invoice, but that requires extending the
|
||||
# account.move.line model with the extra matching fields and extending the EDI extraction
|
||||
# logic to fill these new fields.
|
||||
purchase_line_candidates[purchase_line] = difflib.SequenceMatcher(
|
||||
None, invoice_line.name, purchase_line.name).ratio()
|
||||
|
||||
if len(purchase_line_candidates) > 0:
|
||||
# We take the best match based on the name.
|
||||
purchase_line_match = max(purchase_line_candidates, key=purchase_line_candidates.get)
|
||||
if purchase_line_match:
|
||||
# We found a match. We remove the purchase order line so it does not get matched twice.
|
||||
purchase_lines.remove(purchase_line_match)
|
||||
matched_po_lines.append(purchase_line_match)
|
||||
matched_inv_lines.append((purchase_line_match.id, invoice_line))
|
||||
|
||||
return (matched_po_lines, matched_inv_lines)
|
||||
|
||||
except TimeoutError:
|
||||
_logger.warning('Timed out during search of matching purchase order lines')
|
||||
return ([], [])
|
||||
|
||||
def _set_purchase_orders(self, purchase_orders, force_write=True):
|
||||
"""Link the given purchase orders to this vendor bill and add their lines as invoice lines.
|
||||
|
||||
:param purchase_orders: a list of purchase orders to be linked to this vendor bill
|
||||
:param force_write: whether to delete all existing invoice lines before adding the vendor bill lines
|
||||
"""
|
||||
with self.env.cr.savepoint():
|
||||
with self._get_edi_creation() as invoice:
|
||||
if force_write and invoice.line_ids:
|
||||
invoice.invoice_line_ids = [Command.clear()]
|
||||
for purchase_order in purchase_orders:
|
||||
invoice.invoice_line_ids = [Command.create({
|
||||
'display_type': 'line_section',
|
||||
'name': _('From %s', purchase_order.name)
|
||||
})]
|
||||
invoice.purchase_id = purchase_order
|
||||
invoice._onchange_purchase_auto_complete()
|
||||
|
||||
def _match_purchase_orders(self, po_references, partner_id, amount_total, from_ocr, timeout):
|
||||
"""Tries to match open purchase order lines with this invoice given the information we have.
|
||||
|
||||
:param po_references: a list of potential purchase order references/names
|
||||
:param partner_id: the vendor id inferred from the vendor bill
|
||||
:param amount_total: the total amount of the vendor bill
|
||||
:param from_ocr: indicates whether this vendor bill was created from an OCR scan (less reliable)
|
||||
:param timeout: the max time the line matching algorithm can take before timing out
|
||||
:return: tuple (str, recordset, dict) containing:
|
||||
* the match method:
|
||||
* `total_match`: purchase order reference(s) and total amounts match perfectly
|
||||
* `subset_total_match`: a subset of the referenced purchase orders' lines matches the total amount of
|
||||
this invoice (OCR only)
|
||||
* `po_match`: only the purchase order reference matches (OCR only)
|
||||
* `subset_match`: a subset of the referenced purchase orders' lines matches a subset of the invoice
|
||||
lines based on unit prices (EDI only)
|
||||
* `no_match`: no result found
|
||||
* recordset of `purchase.order.line` containing purchase order lines matched with an invoice line
|
||||
* list of tuple containing every `purchase.order.line` id and its related `account.move.line`
|
||||
"""
|
||||
|
||||
common_domain = [
|
||||
('company_id', '=', self.company_id.id),
|
||||
('state', '=', 'purchase'),
|
||||
('invoice_status', 'in', ('to invoice', 'no'))
|
||||
]
|
||||
|
||||
matching_purchase_orders = self.env['purchase.order']
|
||||
|
||||
# We have purchase order references in our vendor bill and a total amount.
|
||||
if po_references and amount_total:
|
||||
matching_pos |= self.env['purchase.order'].search(common_domain + [('name', 'in', po_references)])
|
||||
# We first try looking for purchase orders whose names match one of the purchase order references in the
|
||||
# vendor bill.
|
||||
matching_purchase_orders |= self.env['purchase.order'].search(
|
||||
common_domain + [('name', 'in', po_references)])
|
||||
|
||||
if not matching_pos:
|
||||
matching_pos |= self.env['purchase.order'].search(common_domain + [('partner_ref', 'in', po_references)])
|
||||
if not matching_purchase_orders:
|
||||
# If not found, we try looking for purchase orders whose `partner_ref` field matches one of the
|
||||
# purchase order references in the vendor bill.
|
||||
matching_purchase_orders |= self.env['purchase.order'].search(
|
||||
common_domain + [('partner_ref', 'in', po_references)])
|
||||
|
||||
if matching_pos:
|
||||
matching_pos_invoice_lines = [{
|
||||
if matching_purchase_orders:
|
||||
# We found matching purchase orders and are extracting all purchase order lines together with their
|
||||
# amounts still to be invoiced.
|
||||
po_lines = [line for line in matching_purchase_orders.order_line if line.product_qty]
|
||||
po_lines_with_amount = [{
|
||||
'line': line,
|
||||
'amount_to_invoice': (1 - line.qty_invoiced / line.product_qty) * line.price_total,
|
||||
} for line in matching_pos.order_line if line.product_qty]
|
||||
} for line in po_lines]
|
||||
|
||||
if amount_total - TOLERANCE < sum(line['amount_to_invoice'] for line in matching_pos_invoice_lines) < amount_total + TOLERANCE:
|
||||
return 'total_match', matching_pos.order_line
|
||||
# If the sum of all remaining amounts to be invoiced for these purchase orders' lines is within a
|
||||
# tolerance from the vendor bill total, we have a total match. We return all purchase order lines
|
||||
# summing up to this vendor bill's total (could be from multiple purchase orders).
|
||||
if (amount_total - TOLERANCE
|
||||
< sum(line['amount_to_invoice'] for line in po_lines_with_amount)
|
||||
< amount_total + TOLERANCE):
|
||||
return 'total_match', matching_purchase_orders.order_line, None
|
||||
|
||||
elif from_ocr:
|
||||
# The invoice comes from an OCR scan.
|
||||
# We try to match the invoice total with purchase order lines.
|
||||
matching_po_lines = self._find_matching_subset_po_lines(
|
||||
po_lines_with_amount, amount_total, timeout)
|
||||
if matching_po_lines:
|
||||
return 'subset_total_match', self.env['purchase.order.line'].union(*matching_po_lines), None
|
||||
else:
|
||||
# We did not find a match for the invoice total.
|
||||
# We return all purchase order lines based only on the purchase order reference(s) in the
|
||||
# vendor bill.
|
||||
return 'po_match', matching_purchase_orders.order_line, None
|
||||
|
||||
else:
|
||||
il_subset = self.find_matching_subset_invoice_lines(matching_pos_invoice_lines, amount_total, timeout)
|
||||
if il_subset:
|
||||
return 'subset_total_match', self.env['purchase.order.line'].union(*[line['line'] for line in il_subset])
|
||||
else:
|
||||
return 'po_match', matching_pos.order_line
|
||||
# We have an invoice from an EDI document, so we try to match individual invoice lines with
|
||||
# individual purchase order lines from referenced purchase orders.
|
||||
matching_po_lines, matching_inv_lines = self._find_matching_po_and_inv_lines(
|
||||
po_lines, self.invoice_line_ids, timeout)
|
||||
|
||||
if matching_po_lines:
|
||||
# We found a subset of purchase order lines that match a subset of the vendor bill lines.
|
||||
# We return the matching purchase order lines and vendor bill lines.
|
||||
return ('subset_match',
|
||||
self.env['purchase.order.line'].union(*matching_po_lines),
|
||||
matching_inv_lines)
|
||||
|
||||
# As a last resort we try matching a purchase order by vendor and total amount.
|
||||
if partner_id and amount_total:
|
||||
purchase_id_domain = common_domain + [('partner_id', 'child_of', [partner_id]), ('amount_total', '>=', amount_total - TOLERANCE), ('amount_total', '<=', amount_total + TOLERANCE)]
|
||||
matching_pos |= self.env['purchase.order'].search(purchase_id_domain)
|
||||
if len(matching_pos) == 1:
|
||||
return 'total_match', matching_pos.order_line
|
||||
purchase_id_domain = common_domain + [
|
||||
('partner_id', 'child_of', [partner_id]),
|
||||
('amount_total', '>=', amount_total - TOLERANCE),
|
||||
('amount_total', '<=', amount_total + TOLERANCE)
|
||||
]
|
||||
matching_purchase_orders = self.env['purchase.order'].search(purchase_id_domain)
|
||||
if len(matching_purchase_orders) == 1:
|
||||
# We found exactly one match on vendor and total amount (within tolerance).
|
||||
# We return all purchase order lines of the purchase order whose total amount matched our vendor bill.
|
||||
return 'total_match', matching_purchase_orders.order_line, None
|
||||
|
||||
return 'no_match', matching_pos.order_line
|
||||
# We couldn't find anything, so we return no lines.
|
||||
return ('no_match', matching_purchase_orders.order_line, None)
|
||||
|
||||
def _find_and_set_purchase_orders(self, po_references, partner_id, amount_total, prefer_purchase_line=False, timeout=10):
|
||||
def _find_and_set_purchase_orders(self, po_references, partner_id, amount_total, from_ocr=False, timeout=10):
|
||||
"""Finds related purchase orders that (partially) match the vendor bill and links the matching lines on this
|
||||
vendor bill.
|
||||
|
||||
:param po_references: a list of potential purchase order references/names
|
||||
:param partner_id: the vendor id matched on the vendor bill
|
||||
:param amount_total: the total amount of the vendor bill
|
||||
:param from_ocr: indicates whether this vendor bill was created from an OCR scan (less reliable)
|
||||
:param timeout: the max time the line matching algorithm can take before timing out
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
method, matched_po_lines = self._match_purchase_orders(po_references, partner_id, amount_total, timeout)
|
||||
method, matched_po_lines, matched_inv_lines = self._match_purchase_orders(
|
||||
po_references, partner_id, amount_total, from_ocr, timeout
|
||||
)
|
||||
|
||||
if method == 'total_match': # erase all lines and autocomplete
|
||||
if method in ('total_match', 'po_match'):
|
||||
# The purchase order reference(s) and total amounts match perfectly or there is only one purchase order
|
||||
# reference that matches with an OCR invoice. We replace the invoice lines with the purchase order lines.
|
||||
self._set_purchase_orders(matched_po_lines.order_id, force_write=True)
|
||||
|
||||
elif method == 'subset_total_match': # don't erase and add autocomplete
|
||||
elif method == 'subset_total_match':
|
||||
# A subset of the referenced purchase order lines matches the total amount of this invoice.
|
||||
# We keep the invoice lines, but add all the lines from the partially matched purchase orders:
|
||||
# * "naively" matched purchase order lines keep their quantity
|
||||
# * unmatched purchase order lines are added with their quantity set to 0
|
||||
self._set_purchase_orders(matched_po_lines.order_id, force_write=False)
|
||||
|
||||
with self._get_edi_creation() as move_form: # logic for unmatched lines
|
||||
unmatched_lines = move_form.invoice_line_ids.filtered(
|
||||
with self._get_edi_creation() as invoice:
|
||||
unmatched_lines = invoice.invoice_line_ids.filtered(
|
||||
lambda l: l.purchase_line_id and l.purchase_line_id not in matched_po_lines)
|
||||
for line in unmatched_lines:
|
||||
if prefer_purchase_line:
|
||||
line.quantity = 0
|
||||
else:
|
||||
line.unlink()
|
||||
invoice.invoice_line_ids = [Command.update(line.id, {'quantity': 0}) for line in unmatched_lines]
|
||||
|
||||
if not prefer_purchase_line:
|
||||
move_form.invoice_line_ids.filtered('purchase_line_id').quantity = 0
|
||||
elif method == 'subset_match':
|
||||
# A subset of the referenced purchase order lines matches a subset of the invoice lines.
|
||||
# We add the purchase order lines, but adjust the quantity to the quantities in the invoice.
|
||||
# The original invoice lines that correspond with a purchase order line are removed.
|
||||
self._set_purchase_orders(matched_po_lines.order_id, force_write=False)
|
||||
|
||||
elif method == 'po_match': # erase all lines and autocomplete
|
||||
if prefer_purchase_line:
|
||||
self._set_purchase_orders(matched_po_lines.order_id, force_write=True)
|
||||
with self._get_edi_creation() as invoice:
|
||||
unmatched_lines = invoice.invoice_line_ids.filtered(
|
||||
lambda l: l.purchase_line_id and l.purchase_line_id not in matched_po_lines)
|
||||
invoice.invoice_line_ids = [Command.delete(line.id) for line in unmatched_lines]
|
||||
|
||||
# We remove the original matched invoice lines and apply their quantities and taxes to the matched
|
||||
# purchase order lines.
|
||||
inv_and_po_lines = list(map(lambda line: (
|
||||
invoice.invoice_line_ids.filtered(
|
||||
lambda l: l.purchase_line_id and l.purchase_line_id.id == line[0]),
|
||||
invoice.invoice_line_ids.filtered(
|
||||
lambda l: l in line[1])
|
||||
),
|
||||
matched_inv_lines
|
||||
))
|
||||
invoice.invoice_line_ids = [
|
||||
Command.update(po_line.id, {'quantity': inv_line.quantity, 'tax_ids': inv_line.tax_ids})
|
||||
for po_line, inv_line in inv_and_po_lines
|
||||
]
|
||||
invoice.invoice_line_ids = [Command.delete(inv_line.id) for dummy, inv_line in inv_and_po_lines]
|
||||
|
||||
# If there are lines left not linked to a purchase order, we add a header
|
||||
unmatched_lines = invoice.invoice_line_ids.filtered(lambda l: not l.purchase_line_id)
|
||||
if len(unmatched_lines) > 0:
|
||||
invoice.invoice_line_ids = [Command.create({
|
||||
'display_type': 'line_section',
|
||||
'name': _('From Electronic Document'),
|
||||
'sequence': -1,
|
||||
})]
|
||||
|
||||
if not any(line.purchase_order_id for line in self.line_ids):
|
||||
self.invoice_origin = False
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
""" Override AccountInvoice_line to add the link to the purchase order line it is related to"""
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
purchase_line_id = fields.Many2one('purchase.order.line', 'Purchase Order Line', ondelete='set null', index='btree_not_null')
|
||||
is_downpayment = fields.Boolean()
|
||||
purchase_line_id = fields.Many2one('purchase.order.line', 'Purchase Order Line', ondelete='set null', index='btree_not_null', copy=False)
|
||||
purchase_order_id = fields.Many2one('purchase.order', 'Purchase Order', related='purchase_line_id.order_id', readonly=True)
|
||||
purchase_line_warn_msg = fields.Text(compute='_compute_purchase_line_warn_msg')
|
||||
|
||||
def _copy_data_extend_business_fields(self, values):
|
||||
# OVERRIDE to copy the 'purchase_line_id' field as well.
|
||||
super(AccountMoveLine, self)._copy_data_extend_business_fields(values)
|
||||
values['purchase_line_id'] = self.purchase_line_id.id
|
||||
|
||||
def _prepare_line_values_for_purchase(self):
|
||||
return [
|
||||
{
|
||||
'product_id': line.product_id.id,
|
||||
'product_qty': line.quantity,
|
||||
'product_uom_id': line.product_uom_id.id,
|
||||
'price_unit': line.price_unit,
|
||||
'discount': line.discount,
|
||||
}
|
||||
for line in self
|
||||
]
|
||||
|
||||
def _related_analytic_distribution(self):
|
||||
# EXTENDS 'account'
|
||||
vals = super()._related_analytic_distribution()
|
||||
if self.purchase_line_id and not self.analytic_distribution:
|
||||
vals |= self.purchase_line_id.analytic_distribution or {}
|
||||
return vals
|
||||
|
||||
@api.depends('product_id.purchase_line_warn_msg')
|
||||
def _compute_purchase_line_warn_msg(self):
|
||||
has_group = self.env.user.has_group('purchase.group_warning_purchase')
|
||||
for line in self:
|
||||
line.purchase_line_warn_msg = line.product_id.purchase_line_warn_msg if has_group else ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue