Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_invoice
from . import analytic_account
from . import analytic_applicability
from . import purchase
from . import product
from . import res_company
from . import res_config_settings
from . import res_partner
from . import mail_compose_message

View file

@ -0,0 +1,286 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import time
from odoo import api, fields, models, Command, _
_logger = logging.getLogger(__name__)
TOLERANCE = 0.02 # tolerance applied to the total when searching for a matching purchase order
class AccountMove(models.Model):
_inherit = 'account.move'
purchase_vendor_bill_id = fields.Many2one('purchase.bill.union', store=False, readonly=True,
states={'draft': [('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)]},
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
@api.onchange('purchase_vendor_bill_id', 'purchase_id')
def _onchange_purchase_auto_complete(self):
r''' Load from either an old purchase order, either an old vendor bill.
When setting a 'purchase.bill.union' in 'purchase_vendor_bill_id':
* If it's a vendor bill, 'invoice_vendor_bill_id' is set and the loading is done by '_onchange_invoice_vendor_bill'.
* If it's a purchase order, 'purchase_id' is set and this method will load lines.
/!\ All this not-stored fields must be empty at the end of this function.
'''
if self.purchase_vendor_bill_id.vendor_bill_id:
self.invoice_vendor_bill_id = self.purchase_vendor_bill_id.vendor_bill_id
self._onchange_invoice_vendor_bill()
elif self.purchase_vendor_bill_id.purchase_order_id:
self.purchase_id = self.purchase_vendor_bill_id.purchase_order_id
self.purchase_vendor_bill_id = False
if not self.purchase_id:
return
# 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')))
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
self.update(invoice_vals)
self.currency_id = new_currency_id
# 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)
)
# 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]
self.purchase_id = False
@api.onchange('partner_id', 'company_id')
def _onchange_partner_id(self):
res = super(AccountMove, self)._onchange_partner_id()
currency_id = (
self.partner_id.property_purchase_currency_id
or self.env['res.currency'].browse(self.env.context.get("default_currency_id"))
or self.currency_id
)
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 = [
('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)
if default_journal_id:
self.journal_id = default_journal_id
self.currency_id = currency_id
return res
@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)
def action_view_source_purchase_orders(self):
self.ensure_one()
source_orders = self.line_ids.purchase_line_id.order_id
result = self.env['ir.actions.act_window']._for_xml_id('purchase.purchase_form_action')
if len(source_orders) > 1:
result['domain'] = [('id', 'in', source_orders.ids)]
elif len(source_orders) == 1:
result['views'] = [(self.env.ref('purchase.purchase_order_form', False).id, 'form')]
result['res_id'] = source_orders.id
else:
result = {'type': 'ir.actions.act_window_close'}
return result
@api.model_create_multi
def create(self, vals_list):
# OVERRIDE
moves = super(AccountMove, self).create(vals_list)
for move in moves:
if move.reversed_entry_id:
continue
purchases = move.line_ids.purchase_line_id.order_id
if not purchases:
continue
refs = [purchase._get_html_link() for purchase in purchases]
message = _("This vendor bill has been created from: %s") % ','.join(refs)
move.message_post(body=message)
return moves
def write(self, vals):
# OVERRIDE
old_purchases = [move.mapped('line_ids.purchase_line_id.order_id') for move in self]
res = super(AccountMove, self).write(vals)
for i, move in enumerate(self):
new_purchases = move.mapped('line_ids.purchase_line_id.order_id')
if not new_purchases:
continue
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)
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):
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)
elif goal - TOLERANCE <= line['amount_to_invoice'] <= goal + TOLERANCE:
solutions.append([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
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)
return subsets[0] if subsets else []
except TimeoutError:
_logger.warning("Timed out during search of a matching subset of invoice 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 _match_purchase_orders(self, po_references, partner_id, amount_total, timeout):
""" Tries to match a purchase order given some bill arguments/hints.
: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)
"""
common_domain = [('company_id', '=', self.company_id.id), ('state', 'in', ('purchase', 'done')), ('invoice_status', 'in', ('to invoice', 'no'))]
matching_pos = self.env['purchase.order']
if po_references and amount_total:
matching_pos |= 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 matching_pos:
matching_pos_invoice_lines = [{
'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]
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
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
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
return 'no_match', matching_pos.order_line
def _find_and_set_purchase_orders(self, po_references, partner_id, amount_total, prefer_purchase_line=False, timeout=10):
self.ensure_one()
method, matched_po_lines = self._match_purchase_orders(po_references, partner_id, amount_total, timeout)
if method == 'total_match': # erase all lines and autocomplete
self._set_purchase_orders(matched_po_lines.order_id, force_write=True)
elif method == 'subset_total_match': # don't erase and add autocomplete
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(
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()
if not prefer_purchase_line:
move_form.invoice_line_ids.filtered('purchase_line_id').quantity = 0
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)
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')
purchase_order_id = fields.Many2one('purchase.order', 'Purchase Order', related='purchase_line_id.order_id', readonly=True)
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

View file

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
class AccountAnalyticAccount(models.Model):
_inherit = 'account.analytic.account'
purchase_order_count = fields.Integer("Purchase Order Count", compute='_compute_purchase_order_count')
@api.depends('line_ids')
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)
])
def action_view_purchase_orders(self):
self.ensure_one()
purchase_orders = self.env['purchase.order'].search([
('order_line.invoice_lines.analytic_line_ids.account_id', '=', self.id)
])
result = {
"type": "ir.actions.act_window",
"res_model": "purchase.order",
"domain": [['id', 'in', purchase_orders.ids]],
"name": _("Purchase Orders"),
'view_mode': 'tree,form',
}
if len(purchase_orders) == 1:
result['view_mode'] = 'form'
result['res_id'] = purchase_orders.id
return result

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class AccountAnalyticApplicability(models.Model):
_inherit = 'account.analytic.applicability'
_description = "Analytic Plan's Applicabilities"
business_domain = fields.Selection(
selection_add=[
('purchase_order', 'Purchase Order'),
],
ondelete={'purchase_order': 'cascade'},
)

View file

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

View file

@ -0,0 +1,99 @@
# -*- 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.tools.float_utils import float_round
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')
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,
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')
def _compute_purchase_method(self):
default_purchase_method = self.env['product.template'].default_get(['purchase_method']).get('purchase_method')
for product in self:
if product.detailed_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
)
@api.model
def get_import_templates(self):
res = super(ProductTemplate, self).get_import_templates()
if self.env.context.get('purchase_product_template'):
return [{
'label': _('Import Template for Products'),
'template': '/purchase/static/xls/product_purchase.xls'
}]
return res
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['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')
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']),
('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])
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)
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['display_name'] = _("Purchase History for %s", self.display_name)
return action
class ProductSupplierinfo(models.Model):
_inherit = "product.supplierinfo"
@api.onchange('partner_id')
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")

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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)
po_lock = fields.Selection([
('edit', 'Allow to edit purchase orders'),
('lock', 'Confirmed purchase orders are not editable')
], string="Purchase Order Modification", default="edit",
help='Purchase Order Modification used when you want to purchase order editable after confirm')
po_double_validation = fields.Selection([
('one_step', 'Confirm purchase orders in one step'),
('two_step', 'Get 2 levels of approvals to confirm a purchase order')
], string="Levels of Approvals", default='one_step',
help="Provide a double validation mechanism for purchases")
po_double_validation_amount = fields.Monetary(string='Double validation amount', default=5000,
help="Minimum amount for which a double validation is required")

View file

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
lock_confirmed_po = fields.Boolean("Lock Confirmed Orders", default=lambda self: self.env.company.po_lock == 'lock')
po_lock = fields.Selection(related='company_id.po_lock', string="Purchase Order Modification *", readonly=False)
po_order_approval = fields.Boolean("Purchase Order Approval", default=lambda self: self.env.company.po_double_validation == 'two_step')
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"""
if self.module_purchase_product_matrix and not self.group_product_variant:
self.module_purchase_product_matrix = False
@api.onchange('module_purchase_product_matrix')
def _onchange_module_purchase_product_matrix(self):
"""The product variant grid requires the product variants activated
If the user enables the product configurator -> enable the product variants as well"""
if self.module_purchase_product_matrix and not self.group_product_variant:
self.group_product_variant = True
def set_values(self):
super().set_values()
po_lock = 'lock' if self.lock_confirmed_po else 'edit'
po_double_validation = 'two_step' if self.po_order_approval else 'one_step'
if self.po_lock != po_lock:
self.po_lock = po_lock
if self.po_double_validation != po_double_validation:
self.po_double_validation = po_double_validation

View file

@ -0,0 +1,44 @@
# -*- 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
class res_partner(models.Model):
_name = 'res.partner'
_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)])
purchase_order_groups = self.env['purchase.order']._read_group(
domain=[('partner_id', 'in', all_partners.ids)],
fields=['partner_id'], groupby=['partner_id']
)
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
@api.model
def _commercial_fields(self):
return super(res_partner, self)._commercial_fields()
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")
purchase_warn_msg = fields.Text('Message for Purchase Order')
receipt_reminder_email = fields.Boolean('Receipt Reminder', default=False, 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,
help="Number of days to send reminder email before the promised receipt date")