mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-22 00:32:05 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
1415
odoo-bringout-oca-ocb-purchase/purchase/models/purchase_order.py
Normal file
1415
odoo-bringout-oca-ocb-purchase/purchase/models/purchase_order.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue