19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -2,11 +2,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_invoice
from . import account_tax
from . import analytic_account
from . import analytic_applicability
from . import purchase
from . import ir_actions_report
from . import purchase_bill_line_match
from . import purchase_order
from . import purchase_order_line
from . import product
from . import res_company
from . import res_config_settings
from . import res_partner
from . import mail_compose_message

View file

@ -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 ""

View file

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from odoo import models
class AccountTax(models.Model):
_inherit = "account.tax"
def _hook_compute_is_used(self, taxes_to_compute):
# OVERRIDE in order to fetch taxes used in purchase
used_taxes = super()._hook_compute_is_used(taxes_to_compute)
taxes_to_compute -= used_taxes
if taxes_to_compute:
self.env['purchase.order.line'].flush_model(['tax_ids'])
self.env.cr.execute("""
SELECT id
FROM account_tax
WHERE EXISTS(
SELECT 1
FROM account_tax_purchase_order_line_rel AS pur
WHERE account_tax_id IN %s
AND account_tax.id = pur.account_tax_id
)
""", [tuple(taxes_to_compute)])
used_taxes.update([tax[0] for tax in self.env.cr.fetchall()])
return used_taxes

View file

@ -13,7 +13,7 @@ class AccountAnalyticAccount(models.Model):
def _compute_purchase_order_count(self):
for account in self:
account.purchase_order_count = self.env['purchase.order'].search_count([
('order_line.invoice_lines.analytic_line_ids.account_id', '=', account.id)
('order_line.invoice_lines.analytic_line_ids.account_id', 'in', account.ids)
])
def action_view_purchase_orders(self):
@ -26,7 +26,7 @@ class AccountAnalyticAccount(models.Model):
"res_model": "purchase.order",
"domain": [['id', 'in', purchase_orders.ids]],
"name": _("Purchase Orders"),
'view_mode': 'tree,form',
'view_mode': 'list,form',
}
if len(purchase_orders) == 1:
result['view_mode'] = 'form'

View file

@ -0,0 +1,56 @@
import io
from odoo import models
from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
# EXTENDS base
collected_streams = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids)
if (
collected_streams
and res_ids
and len(res_ids) == 1
and self._is_purchase_order_report(report_ref)
):
purchase_order = self.env['purchase.order'].browse(res_ids)
builders = purchase_order._get_edi_builders()
if len(builders) == 0:
return collected_streams
# Read pdf content.
pdf_stream = collected_streams[purchase_order.id]['stream']
pdf_content = pdf_stream.getvalue()
reader_buffer = io.BytesIO(pdf_content)
reader = OdooPdfFileReader(reader_buffer, strict=False)
writer = OdooPdfFileWriter()
writer.cloneReaderDocumentRoot(reader)
# Generate and attach EDI documents from each builder
for builder in builders:
xml_content = builder._export_order(purchase_order)
writer.addAttachment(
builder._export_invoice_filename(purchase_order), # works even if it's a SO or PO
xml_content,
subtype='text/xml'
)
# Replace the current content.
pdf_stream.close()
new_pdf_stream = io.BytesIO()
writer.write(new_pdf_stream)
collected_streams[purchase_order.id]['stream'] = new_pdf_stream
return collected_streams
def _is_purchase_order_report(self, report_ref):
return self._get_report(report_ref).report_name in (
'purchase.report_purchasequotation',
'purchase.report_purchaseorder'
)

View file

@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
# purches Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailComposeMessage(models.TransientModel):
_inherit = 'mail.compose.message'
def _action_send_mail(self, auto_commit=False):
if self.model == 'purchase.order':
self = self.with_context(mailing_document_based=True)
if self.env.context.get('mark_rfq_as_sent'):
self = self.with_context(mail_notify_author=self.env.user.partner_id in self.partner_ids)
return super(MailComposeMessage, self)._action_send_mail(auto_commit=auto_commit)

View file

@ -2,39 +2,38 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo.tools.float_utils import float_round
from odoo.exceptions import UserError
from dateutil.relativedelta import relativedelta
class ProductTemplate(models.Model):
_name = 'product.template'
_inherit = 'product.template'
purchased_product_qty = fields.Float(compute='_compute_purchased_product_qty', string='Purchased', digits='Product Unit of Measure')
purchased_product_qty = fields.Float(compute='_compute_purchased_product_qty', string='Purchased', digits='Product Unit')
purchase_method = fields.Selection([
('purchase', 'On ordered quantities'),
('receive', 'On received quantities'),
], string="Control Policy", compute='_compute_purchase_method', default='receive', store=True, readonly=False,
], string="Control Policy", compute='_compute_purchase_method', precompute=True, store=True, readonly=False,
help="On ordered quantities: Control bills based on ordered quantities.\n"
"On received quantities: Control bills based on received quantities.")
purchase_line_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order Line Warning', help=WARNING_HELP, required=True, default="no-message")
purchase_line_warn_msg = fields.Text('Message for Purchase Order Line')
@api.depends('detailed_type')
@api.depends('type')
def _compute_purchase_method(self):
default_purchase_method = self.env['product.template'].default_get(['purchase_method']).get('purchase_method')
default_purchase_method = self.env['product.template'].default_get(['purchase_method']).get('purchase_method', 'receive')
for product in self:
if product.detailed_type == 'service':
if product.type == 'service':
product.purchase_method = 'purchase'
else:
product.purchase_method = default_purchase_method
def _compute_purchased_product_qty(self):
for template in self.with_context(active_test=False):
template.purchased_product_qty = float_round(sum(p.purchased_product_qty for
p in template.product_variant_ids), precision_rounding=template.uom_id.rounding
)
template.purchased_product_qty = template.uom_id.round(sum(p.purchased_product_qty for p in template.product_variant_ids))
def _get_backend_root_menu_ids(self):
return super()._get_backend_root_menu_ids() + [self.env.ref('purchase.menu_purchase_root').id]
@api.model
def get_import_templates(self):
@ -48,42 +47,99 @@ class ProductTemplate(models.Model):
def action_view_po(self):
action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history")
action['domain'] = [
('state', 'in', ['purchase', 'done']),
('product_id', 'in', self.with_context(active_test=False).product_variant_ids.ids),
action['domain'] = ['&',
('state', '=', 'purchase'),
('product_id', 'in', self.with_context(active_test=False).product_variant_ids.ids)
]
action['display_name'] = _("Purchase History for %s", self.display_name)
return action
class ProductProduct(models.Model):
_name = 'product.product'
_inherit = 'product.product'
purchased_product_qty = fields.Float(compute='_compute_purchased_product_qty', string='Purchased',
digits='Product Unit of Measure')
digits='Product Unit')
is_in_purchase_order = fields.Boolean(
compute='_compute_is_in_purchase_order',
search='_search_is_in_purchase_order',
)
def _compute_purchased_product_qty(self):
date_from = fields.Datetime.to_string(fields.Date.context_today(self) - relativedelta(years=1))
domain = [
('order_id.state', 'in', ['purchase', 'done']),
('order_id.state', '=', 'purchase'),
('product_id', 'in', self.ids),
('order_id.date_approve', '>=', date_from)
]
order_lines = self.env['purchase.order.line']._read_group(domain, ['product_id', 'product_uom_qty'], ['product_id'])
purchased_data = dict([(data['product_id'][0], data['product_uom_qty']) for data in order_lines])
order_lines = self.env['purchase.order.line']._read_group(domain, ['product_id'], ['product_uom_qty:sum'])
purchased_data = {product.id: qty for product, qty in order_lines}
for product in self:
if not product.id:
product.purchased_product_qty = 0.0
continue
product.purchased_product_qty = float_round(purchased_data.get(product.id, 0), precision_rounding=product.uom_id.rounding)
product.purchased_product_qty = product.uom_id.round(purchased_data.get(product.id, 0))
@api.depends_context('order_id')
def _compute_is_in_purchase_order(self):
order_id = self.env.context.get('order_id')
if not order_id:
self.is_in_purchase_order = False
return
read_group_data = self.env['purchase.order.line']._read_group(
domain=[('order_id', '=', order_id)],
groupby=['product_id'],
aggregates=['__count'],
)
data = {product.id: count for product, count in read_group_data}
for product in self:
product.is_in_purchase_order = bool(data.get(product.id, 0))
def _search_is_in_purchase_order(self, operator, value):
if operator != 'in':
return NotImplemented
product_ids = self.env['purchase.order.line'].search([
('order_id', 'in', [self.env.context.get('order_id', '')]),
]).product_id.ids
return [('id', 'in', product_ids)]
def action_view_po(self):
action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history")
action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_id', 'in', self.ids)]
action['domain'] = ['&', ('state', '=', 'purchase'), ('product_id', 'in', self.ids)]
action['display_name'] = _("Purchase History for %s", self.display_name)
return action
def _get_backend_root_menu_ids(self):
return super()._get_backend_root_menu_ids() + [self.env.ref('purchase.menu_purchase_root').id]
def _update_uom(self, to_uom_id):
for uom, product, po_lines in self.env['purchase.order.line']._read_group(
[('product_id', 'in', self.ids)],
['product_uom_id', 'product_id'],
['id:recordset'],
):
if uom != product.product_tmpl_id.uom_id:
raise UserError(_(
'As other units of measure (ex : %(problem_uom)s) '
'than %(uom)s have already been used for this product, the change of unit of measure can not be done.'
'If you want to change it, please archive the product and create a new one.',
problem_uom=uom.display_name, uom=product.product_tmpl_id.uom_id.display_name))
po_lines.product_uom_id = to_uom_id
po_lines.flush_recordset()
return super()._update_uom(to_uom_id)
def _trigger_uom_warning(self):
res = super()._trigger_uom_warning()
if res:
return res
po_lines = self.env['purchase.order.line'].sudo().search_count(
[('product_id', 'in', self.ids)], limit=1
)
return bool(po_lines)
class ProductSupplierinfo(models.Model):
_inherit = "product.supplierinfo"
@ -92,8 +148,7 @@ class ProductSupplierinfo(models.Model):
def _onchange_partner_id(self):
self.currency_id = self.partner_id.property_purchase_currency_id.id or self.env.company.currency_id.id
class ProductPackaging(models.Model):
_inherit = 'product.packaging'
purchase = fields.Boolean("Purchase", default=True, help="If true, the packaging can be used for purchase orders")
def _get_filtered_supplier(self, company_id, product_id, params=False):
if params and 'order_id' in params and params['order_id'].company_id:
company_id = params['order_id'].company_id
return super()._get_filtered_supplier(company_id, product_id, params)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,203 @@
# 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 PurchaseBillLineMatch(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', readonly=True)
aml_id = fields.Many2one(comodel_name='account.move.line', readonly=True)
company_id = fields.Many2one(comodel_name='res.company', readonly=True)
partner_id = fields.Many2one(comodel_name='res.partner', readonly=True)
product_id = fields.Many2one(comodel_name='product.product', readonly=True)
line_qty = fields.Float(readonly=True)
line_uom_id = fields.Many2one(comodel_name='uom.uom', readonly=True)
qty_invoiced = fields.Float(readonly=True)
qty_to_invoice = fields.Float('Qty to invoice', readonly=True)
purchase_order_id = fields.Many2one(comodel_name='purchase.order', readonly=True)
account_move_id = fields.Many2one(comodel_name='account.move', readonly=True)
line_amount_untaxed = fields.Monetary(readonly=True)
currency_id = fields.Many2one(comodel_name='res.currency', readonly=True)
state = fields.Char(readonly=True)
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_id as line_uom_id,
pol.qty_invoiced as qty_invoiced,
pol.qty_to_invoice as qty_to_invoice,
po.id as purchase_order_id,
NULL as account_move_id,
pol.price_subtotal as line_amount_untaxed,
po.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 po.state = 'purchase'
AND (pol.product_qty > pol.qty_invoiced OR pol.qty_to_invoice != 0)
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,
am.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 qty_to_invoice,
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)
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
# 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
if len(residual_bill := self.aml_id.move_id) == 1:
# 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"))
partner = self.mapped("partner_id.commercial_partner_id")
if len(partner) > 1:
raise UserError(_("Please select bill lines with the same vendor."))
context = {
'default_partner_id': partner.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,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,725 @@
from collections import defaultdict
from datetime import datetime, time
from dateutil.relativedelta import relativedelta
from pytz import UTC
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, get_lang
from odoo.tools.float_utils import float_compare, float_round
class PurchaseOrderLine(models.Model):
_name = 'purchase.order.line'
_inherit = ['analytic.mixin']
_description = 'Purchase Order Line'
_order = 'order_id, sequence, id'
name = fields.Text(
string='Description', required=True, compute='_compute_price_unit_and_date_planned_and_name', store=True, readonly=False)
sequence = fields.Integer(string='Sequence', default=10)
product_qty = fields.Float(string='Quantity', digits='Product Unit', required=True)
product_uom_qty = fields.Float(string='Total Quantity', compute='_compute_product_uom_qty', store=True)
date_planned = fields.Datetime(
string='Expected Arrival', index=True,
compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True,
help="Delivery date expected from vendor. This date respectively defaults to vendor pricelist lead time then today's date.")
discount = fields.Float(
string="Discount (%)",
compute='_compute_price_unit_and_date_planned_and_name',
digits='Discount',
store=True, readonly=False)
tax_ids = fields.Many2many('account.tax', string='Taxes', context={'active_test': False, 'hide_original_tax_ids': True})
allowed_uom_ids = fields.Many2many('uom.uom', compute='_compute_allowed_uom_ids')
product_uom_id = fields.Many2one('uom.uom', string='Unit', domain="[('id', 'in', allowed_uom_ids)]", ondelete='restrict')
product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], change_default=True, index='btree_not_null', ondelete='restrict')
product_type = fields.Selection(related='product_id.type', readonly=True)
price_unit = fields.Float(
string='Unit Price', required=True, min_display_digits='Product Price', aggregator='avg',
compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True)
price_unit_product_uom = fields.Float(
string='Unit Price Product UoM', min_display_digits='Product Price', compute="_compute_price_unit_product_uom",
help="The Price of one unit of the product's Unit of Measure")
price_unit_discounted = fields.Float('Unit Price (Discounted)', compute='_compute_price_unit_discounted')
price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', store=True)
price_total = fields.Monetary(compute='_compute_amount', string='Total', store=True)
price_tax = fields.Float(compute='_compute_amount', string='Tax', store=True)
order_id = fields.Many2one('purchase.order', string='Order Reference', index=True, required=True, ondelete='cascade')
company_id = fields.Many2one('res.company', related='order_id.company_id', string='Company', store=True, readonly=True)
state = fields.Selection(related='order_id.state')
invoice_lines = fields.One2many('account.move.line', 'purchase_line_id', string="Bill Lines", readonly=True, copy=False)
# Replace by invoiced Qty
qty_invoiced = fields.Float(compute='_compute_qty_invoiced', string="Billed Qty", digits='Product Unit', store=True)
qty_received_method = fields.Selection([('manual', 'Manual')], string="Received Qty Method", compute='_compute_qty_received_method', store=True,
help="According to product configuration, the received quantity can be automatically computed by mechanism:\n"
" - Manual: the quantity is set manually on the line\n"
" - Stock Moves: the quantity comes from confirmed pickings\n")
qty_received = fields.Float("Received Qty", compute='_compute_qty_received', inverse='_inverse_qty_received', compute_sudo=True, store=True, digits='Product Unit')
qty_received_manual = fields.Float("Manual Received Qty", digits='Product Unit', copy=False)
qty_to_invoice = fields.Float(compute='_compute_qty_invoiced', string='To Invoice Quantity', store=True, readonly=True,
digits='Product Unit')
# Same than `qty_received` and `qty_to_invoice` but non-stored and depending of the context.
qty_received_at_date = fields.Float(
string="Received",
compute='_compute_qty_received_at_date',
digits='Product Unit'
)
qty_invoiced_at_date = fields.Float(
string="Billed",
compute='_compute_qty_invoiced_at_date',
digits='Product Unit'
)
amount_to_invoice_at_date = fields.Float(string='Amount', compute='_compute_amount_to_invoice_at_date')
partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True, index='btree_not_null')
currency_id = fields.Many2one(related='order_id.currency_id', string='Currency')
date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True)
date_approve = fields.Datetime(related="order_id.date_approve", string='Confirmation Date', readonly=True)
tax_calculation_rounding_method = fields.Selection(
related='company_id.tax_calculation_rounding_method',
string='Tax calculation rounding method', readonly=True)
display_type = fields.Selection([
('line_section', "Section"),
('line_subsection', "Subsection"),
('line_note', "Note")], default=False, help="Technical field for UX purpose.")
is_downpayment = fields.Boolean()
selected_seller_id = fields.Many2one('product.supplierinfo', compute='_compute_selected_seller_id', help='Technical field to get the vendor pricelist used to generate this line')
_accountable_required_fields = models.Constraint(
'CHECK(display_type IS NOT NULL OR is_downpayment OR (product_id IS NOT NULL AND product_uom_id IS NOT NULL AND date_planned IS NOT NULL))',
'Missing required fields on accountable purchase order line.',
)
_non_accountable_null_fields = models.Constraint(
'CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom_id IS NULL AND date_planned is NULL))',
'Forbidden values on non-accountable purchase order line',
)
product_template_attribute_value_ids = fields.Many2many(related='product_id.product_template_attribute_value_ids', readonly=True)
product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants', ondelete='restrict')
purchase_line_warn_msg = fields.Text(compute='_compute_purchase_line_warn_msg')
parent_id = fields.Many2one(
'purchase.order.line',
string="Parent Section Line",
compute='_compute_parent_id',
)
technical_price_unit = fields.Float(help="Technical field for price computation")
@api.depends('product_qty', 'price_unit', 'tax_ids', 'discount')
def _compute_amount(self):
AccountTax = self.env['account.tax']
for line in self:
company = line.company_id or self.env.company
base_line = line._prepare_base_line_for_taxes_computation()
AccountTax._add_tax_details_in_base_line(base_line, company)
AccountTax._round_base_lines_tax_details([base_line], company)
line.price_subtotal = base_line['tax_details']['total_excluded_currency']
line.price_total = base_line['tax_details']['total_included_currency']
line.price_tax = line.price_total - line.price_subtotal
def _prepare_base_line_for_taxes_computation(self):
""" Convert the current record to a dictionary in order to use the generic taxes computation method
defined on account.tax.
:return: A python dictionary.
"""
self.ensure_one()
company = self.order_id.company_id or self.env.company
return self.env['account.tax']._prepare_base_line_for_taxes_computation(
self,
tax_ids=self.tax_ids,
quantity=self.product_qty,
partner_id=self.order_id.partner_id,
currency_id=self.order_id.currency_id or company.currency_id,
rate=self.order_id.currency_rate,
name=self.name,
)
def _compute_tax_id(self):
for line in self:
line = line.with_company(line.company_id)
fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id._get_fiscal_position(line.order_id.partner_id)
# filter taxes by company
taxes = line.product_id.supplier_taxes_id._filter_taxes_by_company(line.company_id)
line.tax_ids = fpos.map_tax(taxes)
@api.depends('discount', 'price_unit')
def _compute_price_unit_discounted(self):
for line in self:
line.price_unit_discounted = line.price_unit * (1 - line.discount / 100)
@api.depends('product_uom_id', 'price_unit')
def _compute_price_unit_product_uom(self):
for line in self:
line.price_unit_product_uom = not line.display_type and not line.is_downpayment and line.product_uom_id._compute_price(line.price_unit, line.product_id.uom_id)
@api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'qty_received', 'product_uom_qty', 'order_id.state')
def _compute_qty_invoiced(self):
invoiced_quantities = self._prepare_qty_invoiced()
for line in self:
line.qty_invoiced = invoiced_quantities[line]
# compute qty_to_invoice
if line.order_id.state == 'purchase':
if line.product_id.purchase_method == 'purchase':
line.qty_to_invoice = line.product_qty - line.qty_invoiced
else:
line.qty_to_invoice = line.qty_received - line.qty_invoiced
else:
line.qty_to_invoice = 0
@api.depends('qty_invoiced')
@api.depends_context('accrual_entry_date')
def _compute_qty_invoiced_at_date(self):
if not self._date_in_the_past():
for line in self:
line.qty_invoiced_at_date = line.qty_invoiced
return
invoiced_quantities = self._prepare_qty_invoiced()
for line in self:
line.qty_invoiced_at_date = invoiced_quantities[line]
def _prepare_qty_invoiced(self):
# Compute qty_invoiced
invoiced_qties = defaultdict(float)
for line in self:
for inv_line in line._get_invoice_lines():
if inv_line.move_id.state not in ['cancel'] or inv_line.move_id.payment_state == 'invoicing_legacy':
if inv_line.move_id.move_type == 'in_invoice':
invoiced_qties[line] += inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom_id)
elif inv_line.move_id.move_type == 'in_refund':
invoiced_qties[line] -= inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom_id)
return invoiced_qties
def _get_invoice_lines(self):
self.ensure_one()
if self.env.context.get('accrual_entry_date'):
accrual_date = fields.Date.from_string(self.env.context['accrual_entry_date'])
return self.invoice_lines.filtered(
lambda l: l.move_id.invoice_date and l.move_id.invoice_date <= accrual_date
)
else:
return self.invoice_lines
@api.depends('product_id.purchase_line_warn_msg')
def _compute_purchase_line_warn_msg(self):
has_warning_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_warning_group else ""
@api.depends('product_id', 'product_id.type')
def _compute_qty_received_method(self):
for line in self:
if line.product_id and line.product_id.type in ['consu', 'service']:
line.qty_received_method = 'manual'
else:
line.qty_received_method = False
@api.depends('qty_received_method', 'qty_received_manual')
def _compute_qty_received(self):
received_qties = self._prepare_qty_received()
for line in self:
if not line.qty_received or line in received_qties:
line.qty_received = received_qties[line]
@api.depends('qty_received')
@api.depends_context('accrual_entry_date')
def _compute_qty_received_at_date(self):
if not self._date_in_the_past():
for line in self:
line.qty_received_at_date = line.qty_received
return
received_quantities = self._prepare_qty_received()
for line in self:
line.qty_received_at_date = received_quantities[line]
def _prepare_qty_received(self):
received_qties = defaultdict(float)
for line in self:
if line.qty_received_method == 'manual':
received_qties[line] = line.qty_received_manual or 0.0
else:
received_qties[line] = 0.0
return received_qties
@api.onchange('qty_received')
def _inverse_qty_received(self):
""" When writing on qty_received, if the value should be modify manually (`qty_received_method` = 'manual' only),
then we put the value in `qty_received_manual`. Otherwise, `qty_received_manual` should be False since the
received qty is automatically compute by other mecanisms.
"""
for line in self:
if line.qty_received_method == 'manual':
line.qty_received_manual = line.qty_received
else:
line.qty_received_manual = 0.0
@api.depends('product_id', 'product_id.seller_ids', 'partner_id', 'product_qty', 'order_id.date_order', 'product_uom_id')
def _compute_selected_seller_id(self):
for line in self:
if line.product_id:
params = line._get_select_sellers_params()
seller = line.product_id._select_seller(
partner_id=line.partner_id,
quantity=abs(line.product_qty),
date=line.order_id.date_order and line.order_id.date_order.date() or fields.Date.context_today(line),
uom_id=line.product_uom_id,
params=params)
line.selected_seller_id = seller.id if seller else False
else:
line.selected_seller_id = False
@api.depends('price_unit', 'qty_invoiced_at_date', 'qty_received_at_date')
@api.depends_context('accrual_entry_date')
def _compute_amount_to_invoice_at_date(self):
for line in self:
line.amount_to_invoice_at_date = (line.qty_received_at_date - line.qty_invoiced_at_date) * line.price_unit
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
if values.get('display_type', self.default_get(['display_type'])['display_type']):
values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom_id=False, date_planned=False)
else:
values.update(self._prepare_add_missing_fields(values))
if values.get('price_unit') and not values.get('technical_price_unit'):
values['technical_price_unit'] = values['price_unit']
lines = super().create(vals_list)
for line in lines:
if line.product_id and line.order_id.state == 'purchase':
msg = _("Extra line with %s ", line.product_id.display_name)
line.order_id.message_post(body=msg)
return lines
def write(self, vals):
values = vals
if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')):
raise UserError(_("You cannot change the type of a purchase order line. Instead you should delete the current line and create a new line of the proper type."))
if 'product_qty' in values:
precision = self.env['decimal.precision'].precision_get('Product Unit')
for line in self:
if (
line.order_id.state == "purchase"
and float_compare(line.product_qty, values["product_qty"], precision_digits=precision) != 0
):
line.order_id.message_post_with_source(
'purchase.track_po_line_template',
render_values={'line': line, 'product_qty': values['product_qty']},
subtype_xmlid='mail.mt_note',
)
if 'qty_received' in values:
for line in self:
line._track_qty_received(values['qty_received'])
return super().write(values)
@api.ondelete(at_uninstall=False)
def _unlink_except_purchase(self):
for line in self:
if line.order_id.state == 'purchase' and line.display_type not in ['line_section', 'line_subsection', 'line_note']:
state_description = {state_desc[0]: state_desc[1] for state_desc in self._fields['state']._description_selection(self.env)}
raise UserError(_('Cannot delete a purchase order line which is in state “%s”.', state_description.get(line.state)))
@api.model
def _get_date_planned(self, seller, po=False):
"""Return the datetime value to use as Schedule Date (``date_planned``) for
PO Lines that correspond to the given product.seller_ids,
when ordered at `date_order_str`.
:param Model seller: used to fetch the delivery delay (if no seller
is provided, the delay is 0)
:param Model po: purchase.order, necessary only if the PO line is
not yet attached to a PO.
:rtype: datetime
:return: desired Schedule Date for the PO line
"""
date_order = po.date_order if po else self.order_id.date_order
if date_order:
return date_order + relativedelta(days=seller.delay if seller else 0)
else:
return datetime.today() + relativedelta(days=seller.delay if seller else 0)
@api.depends('product_id', 'order_id.partner_id')
def _compute_analytic_distribution(self):
for line in self:
if not line.display_type:
distribution = self.env['account.analytic.distribution.model']._get_distribution({
"product_id": line.product_id.id,
"product_categ_id": line.product_id.categ_id.id,
"partner_id": line.order_id.partner_id.id,
"partner_category_id": line.order_id.partner_id.category_id.ids,
"company_id": line.company_id.id,
})
line.analytic_distribution = distribution or line.analytic_distribution
@api.onchange('product_id')
def onchange_product_id(self):
# TODO: Remove when onchanges are replaced with computes
if not self.product_id or (self.env.context.get('origin_po_id') and self.product_qty):
return
# Reset date, price and quantity since _onchange_quantity will provide default values
self.price_unit = self.product_qty = self.technical_price_unit = 0.0
self._product_id_change()
self._suggest_quantity()
def _product_id_change(self):
if not self.product_id:
return
self.product_uom_id = self.product_id.uom_id
product_lang = self.product_id.with_context(
lang=get_lang(self.env, self.partner_id.lang).code,
partner_id=None,
company_id=self.company_id.id,
)
self.name = self._get_product_purchase_description(product_lang)
self._compute_tax_id()
@api.depends('product_id', 'product_id.uom_id', 'product_id.uom_ids', 'product_id.seller_ids', 'product_id.seller_ids.product_uom_id')
def _compute_allowed_uom_ids(self):
for line in self:
line.allowed_uom_ids = line.product_id.uom_id | line.product_id.uom_ids | line.product_id.seller_ids.product_uom_id
@api.depends('product_qty', 'product_uom_id', 'company_id', 'order_id.partner_id')
def _compute_price_unit_and_date_planned_and_name(self):
for line in self:
if not line.product_id or line.invoice_lines or not line.company_id or self.env.context.get('skip_uom_conversion') or (line.technical_price_unit != line.price_unit):
continue
params = line._get_select_sellers_params()
if line.selected_seller_id or not line.date_planned:
line.date_planned = line._get_date_planned(line.selected_seller_id).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
# If not seller, use the standard price. It needs a proper currency conversion.
if not line.selected_seller_id:
unavailable_seller = line.product_id.seller_ids.filtered(
lambda s: s.partner_id == line.order_id.partner_id)
if not unavailable_seller and line.price_unit and line.product_uom_id == line._origin.product_uom_id:
# Avoid to modify the price unit if there is no price list for this partner and
# the line has already one to avoid to override unit price set manually.
continue
line.discount = 0
po_line_uom = line.product_uom_id or line.product_id.uom_id
price_unit = line.env['account.tax']._fix_tax_included_price_company(
line.product_id.uom_id._compute_price(line.product_id.standard_price, po_line_uom),
line.product_id.supplier_taxes_id,
line.tax_ids,
line.company_id,
)
price_unit = line.product_id.cost_currency_id._convert(
price_unit,
line.currency_id,
line.company_id,
line.date_order or fields.Date.context_today(line),
False
)
line.price_unit = line.technical_price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price')))
elif line.selected_seller_id:
price_unit = line.env['account.tax']._fix_tax_included_price_company(line.selected_seller_id.price, line.product_id.supplier_taxes_id, line.tax_ids, line.company_id) if line.selected_seller_id else 0.0
price_unit = line.selected_seller_id.currency_id._convert(price_unit, line.currency_id, line.company_id, line.date_order or fields.Date.context_today(line), False)
price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price')))
line.price_unit = line.technical_price_unit = line.selected_seller_id.product_uom_id._compute_price(price_unit, line.product_uom_id)
line.discount = line.selected_seller_id.discount or 0.0
# record product names to avoid resetting custom descriptions
default_names = []
vendors = line.product_id._prepare_sellers(params=params)
product_ctx = {'seller_id': None, 'partner_id': None, 'lang': get_lang(line.env, line.partner_id.lang).code}
default_names.append(line._get_product_purchase_description(line.product_id.with_context(product_ctx)))
for vendor in vendors:
product_ctx = {'seller_id': vendor.id, 'lang': get_lang(line.env, line.partner_id.lang).code}
default_names.append(line._get_product_purchase_description(line.product_id.with_context(product_ctx)))
if not line.name or line.name in default_names:
product_ctx = {'seller_id': line.selected_seller_id.id, 'lang': get_lang(line.env, line.partner_id.lang).code}
line.name = line._get_product_purchase_description(line.product_id.with_context(product_ctx))
@api.depends('product_uom_id', 'product_qty', 'product_id.uom_id')
def _compute_product_uom_qty(self):
for line in self:
if line.product_id and line.product_id.uom_id != line.product_uom_id:
line.product_uom_qty = line.product_uom_id._compute_quantity(line.product_qty, line.product_id.uom_id)
else:
line.product_uom_qty = line.product_qty
def _get_gross_price_unit(self):
self.ensure_one()
price_unit = self.price_unit
if self.discount:
price_unit = price_unit * (1 - self.discount / 100)
if self.tax_ids:
qty = self.product_qty or 1
price_unit = self.tax_ids.compute_all(
price_unit,
currency=self.order_id.currency_id,
quantity=qty,
rounding_method='round_globally',
)['total_void']
price_unit = price_unit / qty
if self.product_uom_id.id != self.product_id.uom_id.id:
price_unit *= self.product_id.uom_id.factor / self.product_uom_id.factor
return price_unit
def _compute_parent_id(self):
purchase_order_lines = set(self)
for order, lines in self.grouped('order_id').items():
if not order:
lines.parent_id = False
continue
last_section = False
last_sub = False
for line in order.order_line.sorted('sequence'):
if line.display_type == 'line_section':
last_section = line
if line in purchase_order_lines:
line.parent_id = False
last_sub = False
elif line.display_type == 'line_subsection':
if line in purchase_order_lines:
line.parent_id = last_section
last_sub = line
elif line in purchase_order_lines:
line.parent_id = last_sub or last_section
def action_add_from_catalog(self):
order = self.env['purchase.order'].browse(self.env.context.get('order_id'))
return order.with_context(child_field='order_line').action_add_from_catalog()
def _suggest_quantity(self):
''' Suggest a minimal quantity based on the seller
'''
if not self.product_id:
return
date = self.order_id.date_order and self.order_id.date_order.date() or fields.Date.context_today(self)
seller_min_qty = self.product_id.seller_ids\
.filtered(lambda r: r.partner_id == self.order_id.partner_id and
(not r.product_id or r.product_id == self.product_id) and
(not r.date_start or r.date_start <= date) and
(not r.date_end or r.date_end >= date))\
.sorted(key=lambda r: r.min_qty)
if seller_min_qty:
self.product_qty = seller_min_qty[0].min_qty or 1.0
self.product_uom_id = seller_min_qty[0].product_uom_id
else:
self.product_qty = 1.0
def _get_product_catalog_lines_data(self, **kwargs):
""" Return information about purchase order lines in `self`.
If `self` is empty, this method returns only the default value(s) needed for the product
catalog. In this case, the quantity that equals 0.
Otherwise, it returns a quantity and a price based on the product of the POL(s) and whether
the product is read-only or not.
A product is considered read-only if the order is considered read-only (see
``PurchaseOrder._is_readonly`` for more details) or if `self` contains multiple records.
Note: This method cannot be called with multiple records that have different products linked.
:raise odoo.exceptions.ValueError: ``len(self.product_id) != 1``
:rtype: dict
:return: A dict with the following structure:
{
'quantity': float,
'price': float,
'readOnly': bool,
'uomDisplayName': String,
'packaging': dict,
'warning': String,
}
"""
if len(self) == 1:
catalog_info = self.order_id._get_product_price_and_data(self.product_id)
catalog_info.update(
quantity=self.product_qty,
price=self.price_unit * (1 - self.discount / 100),
readOnly=self.order_id._is_readonly(),
)
if self.product_id.uom_id != self.product_uom_id:
catalog_info['uomDisplayName'] = self.product_uom_id.display_name
return catalog_info
elif self:
self.product_id.ensure_one()
order_line = self[0]
catalog_info = order_line.order_id._get_product_price_and_data(order_line.product_id)
catalog_info['quantity'] = sum(self.mapped(
lambda line: line.product_uom_id._compute_quantity(
qty=line.product_qty,
to_unit=line.product_id.uom_id,
)))
catalog_info['readOnly'] = True
return catalog_info
return {'quantity': 0}
def _get_product_purchase_description(self, product_lang):
self.ensure_one()
name = product_lang.display_name
if product_lang.description_purchase:
name += '\n' + product_lang.description_purchase
return name
def _prepare_account_move_line(self, move=False):
self.ensure_one()
aml_currency = move and move.currency_id or self.currency_id
date = move and move.date or fields.Date.today()
res = {
'display_type': self.display_type or 'product',
'name': self.env['account.move.line']._get_journal_items_full_name(self.name, self.product_id.display_name),
'product_id': self.product_id.id,
'product_uom_id': self.product_uom_id.id,
'quantity': -self.qty_to_invoice if move and move.move_type == 'in_refund' else self.qty_to_invoice,
'discount': self.discount,
'price_unit': self.currency_id._convert(self.price_unit, aml_currency, self.company_id, date, round=False),
'tax_ids': [(6, 0, self.tax_ids.ids)],
'purchase_line_id': self.id,
'is_downpayment': self.is_downpayment,
}
return res
@api.model
def _prepare_add_missing_fields(self, values):
""" Deduce missing required fields from the onchange """
res = {}
onchange_fields = ['name', 'price_unit', 'product_qty', 'product_uom_id', 'tax_ids', 'date_planned']
if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields):
line = self.new(values)
line.onchange_product_id()
for field in onchange_fields:
if field not in values:
res[field] = line._fields[field].convert_to_write(line[field], line)
return res
@api.model
def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, partner_id, po):
values = self.env.context.get('procurement_values', {})
uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_id, rounding_method='HALF-UP')
# _select_seller is used if the supplier have different price depending
# the quantities ordered.
today = fields.Date.today()
seller = product_id.with_company(company_id)._select_seller(
partner_id=partner_id,
quantity=product_qty if values.get('force_uom') else uom_po_qty,
date=po.date_order and max(po.date_order.date(), today) or today,
uom_id=product_uom if values.get('force_uom') else product_id.uom_id,
params={'force_uom': values.get('force_uom')}
)
if seller and (seller.product_uom_id or seller.product_tmpl_id.uom_id) != product_uom:
uom_po_qty = product_id.uom_id._compute_quantity(uom_po_qty, seller.product_uom_id, rounding_method='HALF-UP')
tax_domain = self.env['account.tax']._check_company_domain(company_id)
product_taxes = product_id.supplier_taxes_id.filtered_domain(tax_domain)
taxes = po.fiscal_position_id.map_tax(product_taxes)
if seller:
price_unit = (seller.product_uom_id._compute_price(seller.price, product_uom) if product_uom else seller.price)
price_unit = self.env['account.tax']._fix_tax_included_price_company(
price_unit, product_taxes, taxes, company_id)
else:
price_unit = 0
if price_unit and seller and po.currency_id and seller.currency_id != po.currency_id:
price_unit = seller.currency_id._convert(
price_unit, po.currency_id, po.company_id, po.date_order or fields.Date.today())
product_lang = product_id.with_prefetch().with_context(
lang=partner_id.lang,
partner_id=partner_id.id,
)
name = product_lang.with_context(seller_id=seller.id).display_name
if product_lang.description_purchase:
name += '\n' + product_lang.description_purchase
date_planned = self.order_id.date_planned or self._get_date_planned(seller, po=po)
discount = seller.discount or 0.0
return {
'name': name,
'product_qty': product_qty if product_uom else uom_po_qty,
'product_id': product_id.id,
'product_uom_id': product_uom.id or seller.product_uom_id.id,
'price_unit': price_unit,
'date_planned': date_planned,
'tax_ids': [(6, 0, taxes.ids)],
'order_id': po.id,
'discount': discount,
}
def _convert_to_middle_of_day(self, date):
"""Return a datetime which is the noon of the input date(time) according
to order user's time zone, convert to UTC time.
"""
return self.order_id.get_order_timezone().localize(datetime.combine(date, time(12))).astimezone(UTC).replace(tzinfo=None)
@api.model
def _date_in_the_past(self):
if not 'accrual_entry_date' in self.env.context:
return False
accrual_date = fields.Date.from_string(self.env.context['accrual_entry_date'])
return accrual_date < fields.Date.today()
def _update_date_planned(self, updated_date):
self.date_planned = updated_date
def _track_qty_received(self, new_qty):
self.ensure_one()
# don't track anything when coming from the accrued expense entry wizard, as it is only computing fields at a past date to get relevant amounts
# and doesn't actually change anything to the current record
if self.env.context.get('accrual_entry_date'):
return
if new_qty != self.qty_received and self.order_id.state == 'purchase':
self.order_id.message_post_with_source(
'purchase.track_po_line_qty_received_template',
render_values={'line': self, 'qty_received': new_qty},
subtype_xmlid='mail.mt_note',
)
def _validate_analytic_distribution(self):
for line in self:
if line.display_type:
continue
line._validate_distribution(
product=line.product_id.id,
business_domain='purchase_order',
company_id=line.company_id.id,
)
def action_open_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'purchase.order',
'res_id': self.order_id.id,
'view_mode': 'form',
}
def _merge_po_line(self, rfq_line):
self.product_qty += rfq_line.product_qty
self.price_unit = min(self.price_unit, rfq_line.price_unit)
def _get_select_sellers_params(self):
self.ensure_one()
return {
"order_id": self.order_id,
"force_uom": True,
}
def get_parent_section_line(self):
if not self.display_type and self.parent_id.display_type == 'line_subsection':
return self.parent_id.parent_id
return self.parent_id

View file

@ -3,14 +3,9 @@
from odoo import fields, models
class Company(models.Model):
_inherit = 'res.company'
po_lead = fields.Float(string='Purchase Lead Time', required=True,
help="Margin of error for vendor lead times. When the system "
"generates Purchase Orders for procuring products, "
"they will be scheduled that many days earlier "
"to cope with unexpected vendor delays.", default=0.0)
class ResCompany(models.Model):
_inherit = 'res.company'
po_lock = fields.Selection([
('edit', 'Allow to edit purchase orders'),

View file

@ -13,30 +13,14 @@ class ResConfigSettings(models.TransientModel):
po_double_validation = fields.Selection(related='company_id.po_double_validation', string="Levels of Approvals *", readonly=False)
po_double_validation_amount = fields.Monetary(related='company_id.po_double_validation_amount', string="Minimum Amount", currency_field='company_currency_id', readonly=False)
company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id', string="Company Currency", readonly=True)
default_purchase_method = fields.Selection([
('purchase', 'Ordered quantities'),
('receive', 'Received quantities'),
], string="Bill Control", default_model="product.template",
help="This default value is applied to any new product created. "
"This can be changed in the product detail form.", default="receive")
group_warning_purchase = fields.Boolean("Purchase Warnings", implied_group='purchase.group_warning_purchase')
module_account_3way_match = fields.Boolean("3-way matching: purchases, receptions and bills")
module_purchase_requisition = fields.Boolean("Purchase Agreements")
module_purchase_product_matrix = fields.Boolean("Purchase Grid Entry")
po_lead = fields.Float(related='company_id.po_lead', readonly=False)
use_po_lead = fields.Boolean(
string="Security Lead Time for Purchase",
config_parameter='purchase.use_po_lead',
help="Margin of error for vendor lead times. When the system generates Purchase Orders for reordering products,they will be scheduled that many days earlier to cope with unexpected vendor delays.")
group_send_reminder = fields.Boolean("Receipt Reminder", implied_group='purchase.group_send_reminder', default=True,
help="Allow automatically send email to remind your vendor the receipt date")
@api.onchange('use_po_lead')
def _onchange_use_po_lead(self):
if not self.use_po_lead:
self.po_lead = 0.0
@api.onchange('group_product_variant')
def _onchange_group_product_variant_purchase(self):
"""If the user disables the product variants -> disable the product configurator as well"""

View file

@ -1,44 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo import api, fields, models, _
class res_partner(models.Model):
_name = 'res.partner'
class ResPartner(models.Model):
_inherit = 'res.partner'
def _compute_purchase_order_count(self):
# retrieve all children partners
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
self.purchase_order_count = 0
if not self.env.user._has_group('purchase.group_purchase_user'):
return
# retrieve all children partners and prefetch 'parent_id' on them
all_partners = self.with_context(active_test=False).search_fetch(
[('id', 'child_of', self.ids)],
['parent_id'],
)
purchase_order_groups = self.env['purchase.order']._read_group(
domain=[('partner_id', 'in', all_partners.ids)],
fields=['partner_id'], groupby=['partner_id']
groupby=['partner_id'], aggregates=['__count'],
)
partners = self.browse()
for group in purchase_order_groups:
partner = self.browse(group['partner_id'][0])
while partner:
if partner in self:
partner.purchase_order_count += group['partner_id_count']
partners |= partner
partner = partner.parent_id
(self - partners).purchase_order_count = 0
self_ids = set(self._ids)
@api.model
def _commercial_fields(self):
return super(res_partner, self)._commercial_fields()
for partner, count in purchase_order_groups:
while partner:
if partner.id in self_ids:
partner.purchase_order_count += count
partner = partner.parent_id
property_purchase_currency_id = fields.Many2one(
'res.currency', string="Supplier Currency", company_dependent=True,
help="This currency will be used, instead of the default one, for purchases from the current partner")
purchase_order_count = fields.Integer(compute='_compute_purchase_order_count', string='Purchase Order Count')
purchase_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order Warning', help=WARNING_HELP, default="no-message")
help="This currency will be used for purchases from the current partner")
purchase_order_count = fields.Integer(
string="Purchase Order Count",
groups='purchase.group_purchase_user',
compute='_compute_purchase_order_count',
)
purchase_warn_msg = fields.Text('Message for Purchase Order')
receipt_reminder_email = fields.Boolean('Receipt Reminder', default=False, company_dependent=True,
receipt_reminder_email = fields.Boolean('Receipt Reminder', company_dependent=True,
help="Automatically send a confirmation email to the vendor X days before the expected receipt date, asking him to confirm the exact date.")
reminder_date_before_receipt = fields.Integer('Days Before Receipt', default=1, company_dependent=True,
reminder_date_before_receipt = fields.Integer('Days Before Receipt', company_dependent=True,
help="Number of days to send reminder email before the promised receipt date")
buyer_id = fields.Many2one('res.users', string='Buyer')
def _compute_application_statistics_hook(self):
data_list = super()._compute_application_statistics_hook()
if not self.env.user.has_group('purchase.group_purchase_user'):
return data_list
for partner in self.filtered(lambda partner: partner.purchase_order_count):
stat_info = {'iconClass': 'fa-credit-card', 'value': partner.purchase_order_count, 'label': _('Purchases'), 'tagClass': 'o_tag_color_5'}
data_list[partner.id].append(stat_info)
return data_list