Merge branch '18.0' of git.bemade.org:bemade/bemade-addons into 18.0

This commit is contained in:
Benoît Vézina 2025-03-06 10:21:59 -05:00
commit 7c5bc93454
17 changed files with 463 additions and 222 deletions

View file

@ -48,14 +48,20 @@ class BaseAIProviderInstance(models.Model):
api_key = fields.Char(
string='API Key',
help='API key if required by the provider',
invisible=lambda self: self.provider_type == 'ollama'
invisible="[('provider_type', '=', 'ollama')]" # Hide when provider type is ollama
)
@api.onchange('provider_id')
def _onchange_provider_id(self):
if self.provider_id:
self.provider_type = self.provider_id.code
@api.model
def get_default_instance(self):
"""Get the default AI provider instance to use.
Returns:
ai.provider.instance: The default instance to use, or raises UserError if none found
"""
instance = self.env['ai.provider.instance'].search([('active', '=', True)], limit=1)
if not instance:
raise UserError(_('No active AI provider instance found. Please configure one in the settings.'))
return instance
model_ids = fields.One2many(
'ai.model',
@ -76,6 +82,10 @@ class BaseAIProviderInstance(models.Model):
help='Maximum number of retry attempts for failed API calls'
)
@api.model
def _valid_field_parameter(self, field, name):
return name == 'invisible' or super()._valid_field_parameter(field, name)
_sql_constraints = [
('name_uniq',
'unique(name)',
@ -94,4 +104,3 @@ class BaseAIProviderInstance(models.Model):
self.ensure_one()
if self.provider_type == 'none':
raise UserError(_('Please select a provider type'))

View file

@ -1,6 +1,6 @@
{
'name': 'Ollama Integration',
'version': '1.0',
'version': '1.0.0',
'category': 'Technical',
'summary': 'Integration with Ollama AI models',
'description': """
@ -21,6 +21,7 @@ in your Odoo instance. Features include:
],
'data': [
'data/ai_provider_data.xml',
'data/ai_provider_instance_data.xml',
'views/ollama_stats_views.xml',
'views/ai_provider_instance_views.xml',
'security/ir.model.access.csv',

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Default Ollama Provider Instance -->
<record id="ai_provider_instance_ollama_default" model="ai.provider.instance">
<field name="name">Ollama Local</field>
<field name="provider_type">ollama</field>
<field name="host">http://localhost:11434</field>
<field name="model_name">llama3.2</field>
<field name="temperature">0.7</field>
<field name="top_k">40</field>
<field name="top_p">0.9</field>
<field name="repeat_penalty">1.1</field>
<field name="num_ctx">4096</field>
<field name="num_predict">1024</field>
<field name="min_p">0.05</field>
<field name="repeat_last_n">64</field>
<field name="seed">0</field>
<field name="num_gpu">1</field>
<field name="num_thread">8</field>
<field name="mirostat">0</field>
<field name="mirostat_tau">5.0</field>
<field name="mirostat_eta">0.1</field>
<field name="num_batch">8</field>
<field name="num_keep">0</field>
<field name="tfs_z">1.0</field>
<field name="skip_special_tokens" eval="True"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,17 @@
def migrate(cr, version):
# Add num_predict column if it doesn't exist
cr.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='ai_provider_instance'
AND column_name='num_predict'
) THEN
ALTER TABLE ai_provider_instance
ADD COLUMN num_predict integer DEFAULT 1024;
END IF;
END
$$;
""")

View file

@ -36,16 +36,16 @@ class OllamaAIProviderInstance(models.Model):
else:
# Clear Ollama-specific fields
self.update({
'num_ctx': False,
'temperature': False,
'top_p': False,
'top_k': False,
'repeat_penalty': False,
'repeat_last_n': False,
'num_thread': False,
'num_gpu': False,
'num_batch': False,
'model_name': False,
'num_ctx': False, # Context length
'temperature': False, # Sampling temperature
'top_p': False, # Nucleus sampling threshold
'top_k': False, # Top-k sampling threshold
'repeat_penalty': False, # Penalty for repeated tokens
'repeat_last_n': False, # Number of tokens to consider for repeat penalty
'num_thread': False, # Number of CPU threads to use
'num_gpu': False, # Number of GPUs to use
'num_batch': False, # Batch size for inference
'model_name': False, # Model name/path
})
# Override default host for Ollama
@ -53,34 +53,6 @@ class OllamaAIProviderInstance(models.Model):
default='http://localhost:11434',
help='Ollama server host URL')
@api.onchange('provider_type')
def _onchange_provider_type(self):
"""Handle provider type changes.
When switching to 'ollama':
- Set the provider_id to the Ollama provider
- Set default host if empty
When switching away from 'ollama':
- Clear Ollama-specific fields
"""
if self.provider_type == 'ollama':
# Find and set the Ollama provider
ollama_provider = self.env['ai.provider'].search([('code', '=', 'ollama')], limit=1)
if ollama_provider:
self.provider_id = ollama_provider.id
if not self.host:
self.host = ollama_provider.default_host
else:
# Clear Ollama-specific fields
self.update({
'num_ctx': False, # Context length
'temperature': False, # Sampling temperature
'top_p': False, # Nucleus sampling threshold
'top_k': False, # Top-k sampling threshold
'repeat_penalty': False, # Penalty for repeated tokens
})
def test_connection(self):
"""Test the connection to the Ollama server.
@ -155,16 +127,27 @@ class OllamaAIProviderInstance(models.Model):
existing.write(vals)
else:
self.env['ai.model'].create(vals)
# Invalidate the cache to force reload of related records
self.invalidate_recordset(['model_ids'])
# Return action to reload the view completely
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'ir.actions.act_window',
'res_model': 'ai.provider.instance',
'res_id': self.id,
'view_mode': 'form',
'target': 'current',
'flags': {
'mode': 'readonly',
'reload': True, # Force reload
},
'context': {'notification': {
'type': 'success',
'title': _('Success'),
'message': _('Successfully synchronized %d models', len(models)),
'sticky': False,
'type': 'success',
}
}}
}
except Exception as e:
raise UserError(_('Model synchronization failed: %s', str(e)))

View file

@ -1,4 +1,10 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import requests
import logging
import json
_logger = logging.getLogger(__name__)
class OllamaProviderMixin(models.AbstractModel):
"""Mixin model that provides Ollama-specific configuration parameters.
@ -27,7 +33,7 @@ class OllamaProviderMixin(models.AbstractModel):
string='Model Name',
help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)',
required=True,
default='llama2')
default='deepseek-r1:32b')
# Context Window Configuration
num_ctx = fields.Integer(
@ -35,7 +41,7 @@ class OllamaProviderMixin(models.AbstractModel):
help='Maximum number of tokens to consider for context. A larger context window allows '
'the model to access more historical information but requires more memory. '
'Range: [0 - 32768].',
default=4096)
default=8192)
# Generation Parameters
temperature = fields.Float(
@ -43,32 +49,14 @@ class OllamaProviderMixin(models.AbstractModel):
help='Controls randomness in the output. Higher values make the output more random, '
'while lower values make it more focused and deterministic. '
'Range: [0.0 - 2.0]',
default=0.8)
default=0.7)
top_p = fields.Float(
string='Top P',
help='Nucleus sampling: only consider the tokens whose cumulative probability exceeds '
'this value. Lower values make the output more focused. '
'Range: [0.0 - 1.0]',
string='Top P (Nucleus Sampling)',
help='Limits the cumulative probability of tokens to sample from. Only the most likely '
'tokens with total probability mass of top_p are considered. '
'Range: [0.0 - 1.0].',
default=0.9)
top_k = fields.Integer(
string='Top K',
help='Only consider the top K tokens for text generation. Lower values make the '
'output more focused. Set to 0 to disable. '
'Range: [0 - 100]',
default=40)
repeat_penalty = fields.Float(
string='Repeat Penalty',
help='Penalty for repeating tokens. Higher values make the output less repetitive. '
'Range: [0.0 - 2.0]',
default=1.1)
# Advanced Configuration
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where the model should stop generating further tokens.')
top_k = fields.Integer(
string='Top K',
@ -77,13 +65,6 @@ class OllamaProviderMixin(models.AbstractModel):
'Range: [1 - 100].',
default=40)
top_p = fields.Float(
string='Top P (Nucleus Sampling)',
help='Limits the cumulative probability of tokens to sample from. Only the most likely '
'tokens with total probability mass of top_p are considered. '
'Range: [0.0 - 1.0].',
default=0.9)
min_p = fields.Float(
string='Min P',
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
@ -96,10 +77,97 @@ class OllamaProviderMixin(models.AbstractModel):
default=1.1,
digits=(3, 2))
# Advanced Configuration
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where the model should stop generating further tokens.')
num_predict = fields.Integer(
string='Maximum Tokens',
help='Maximum number of tokens to predict. Set to -1 for unlimited.',
default=2048)
repeat_last_n = fields.Integer(
string='Repeat Last N',
help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.',
default=64)
default=64
)
def generate_text(self, prompt, **kwargs):
"""Generate text using the Ollama API.
Args:
prompt (str): The prompt to generate text from
**kwargs: Additional parameters to pass to the API
Returns:
str: The generated text
"""
self.ensure_one()
# Prepare the request
url = f"{self.host}/api/generate"
# Build the request data
data = {
'model': self.model_name,
'prompt': prompt,
'stream': False,
'num_ctx': self.num_ctx,
'temperature': self.temperature,
'top_k': self.top_k,
'top_p': self.top_p,
'repeat_penalty': self.repeat_penalty,
'repeat_last_n': self.repeat_last_n,
'num_predict': self.num_predict,
'min_p': self.min_p,
'seed': self.seed,
'num_gpu': self.num_gpu,
'num_thread': self.num_thread,
'mirostat': int(self.mirostat),
'mirostat_tau': self.mirostat_tau,
'mirostat_eta': self.mirostat_eta,
'num_batch': self.num_batch,
'num_keep': self.num_keep,
'tfs_z': self.tfs_z,
'skip_special_tokens': self.skip_special_tokens
}
# Add any additional parameters
if kwargs:
data.update(kwargs)
# Make the request
try:
_logger = logging.getLogger(__name__)
_logger.info("Sending request to Ollama API with data: %s", data)
response = requests.post(url, json=data, timeout=self.timeout)
response.raise_for_status()
# Log the raw response
_logger.info("Raw API response: %s", response.text)
# Parse the response
result = response.json()
response_text = result.get('response', '')
# Si la réponse est une chaîne JSON, la parser
try:
if isinstance(response_text, str):
parsed_response = json.loads(response_text)
_logger.info("Parsed nested JSON response: %s", parsed_response)
return parsed_response
else:
_logger.info("Direct response: %s", response_text)
return response_text
except json.JSONDecodeError:
# Si ce n'est pas du JSON valide, retourner le texte tel quel
_logger.info("Non-JSON response: %s", response_text)
return response_text
except requests.exceptions.RequestException as e:
raise UserError(_('Failed to generate text: %s') % str(e))
# Advanced Generation Parameters
seed = fields.Integer(
@ -174,6 +242,7 @@ class OllamaProviderMixin(models.AbstractModel):
"""Get Ollama-specific options for API calls."""
self.ensure_one()
options = {
'model': self.model_name,
'temperature': self.temperature,
'num_ctx': self.num_ctx,
'num_predict': self.num_predict,
@ -192,6 +261,7 @@ class OllamaProviderMixin(models.AbstractModel):
'num_keep': self.num_keep,
'tfs_z': self.tfs_z,
'skip_special_tokens': self.skip_special_tokens,
'stream': False
}
if self.stop_sequences:

View file

@ -19,7 +19,7 @@
#
{
"name": "Carrier Accounts by Partner",
"version": "18.0.0.1.1",
"version": "18.0.0.1.2",
"summary": "Add one or many carrier accounts per partner",
"category": "Delivery",
"author": "Bemade Inc.",

View file

@ -100,32 +100,24 @@ class CarrierAccountMixin(models.AbstractModel):
Returns:
Tuple containing:
- partners: recordset of partners that can have valid accounts
- is_third_party: boolean indicating if this is a third party billing mode
- invalid_partners: for third party mode, partners that cannot own the account
"""
self.ensure_one()
invalid_partners = self.env["res.partner"].browse()
invalid_partners = self.env["res.partner"]
match self.delivery_billing_mode:
case "collect":
return (
self.recipient_id | self.recipient_id.commercial_partner_id,
False,
invalid_partners,
)
return self.recipient_id | self.recipient_id.commercial_partner_id
case "prepaid" | "ppc" | "no charge":
return (
self.sender_id | self.sender_id.commercial_partner_id,
False,
invalid_partners,
)
return self.sender_id | self.sender_id.commercial_partner_id
case "third party":
invalid_partners = (
self.recipient_id | self.recipient_id.commercial_partner_id
) | (self.sender_id | self.sender_id.commercial_partner_id)
return self.env["res.partner"].browse(), True, invalid_partners
return self.env["res.partner"].search(
[("id", "not in", invalid_partners.ids)]
)
case _:
return self.env["res.partner"].browse(), False, invalid_partners
return self.env["res.partner"]
@api.depends(
"sender_id",
@ -134,40 +126,26 @@ class CarrierAccountMixin(models.AbstractModel):
)
def _compute_carrier_id(self):
for rec in self:
# Only set carrier if not already set
if not rec.carrier_id:
rec.carrier_id = rec._get_default_carrier()
continue
elif (
rec.delivery_billing_mode
and rec.carrier_id not in rec.valid_carrier_ids
and not rec._has_valid_account_for_carrier()
):
rec.carrier_id = rec._get_default_carrier()
# Don't reset carrier if we're changing billing mode and there's a valid account
if rec.delivery_billing_mode:
partners, is_third_party, invalid_partners = (
rec._get_valid_carrier_partners()
)
def _has_valid_account_for_carrier(self):
"""Check if there's a valid account for the current carrier and billing mode."""
self.ensure_one()
if not self.carrier_id or not self.delivery_billing_mode:
return False
if is_third_party:
# For third party, check if there's any account for this carrier
# that doesn't belong to sender or recipient
has_valid_account = bool(
self.env["delivery.carrier.account"].search_count(
[
("delivery_carrier_id", "=", rec.carrier_id.id),
("partner_id", "not in", invalid_partners.ids),
]
)
)
if has_valid_account:
continue
else:
# Check if there's a valid account for this carrier
if any(
account.delivery_carrier_id == rec.carrier_id
for account in partners.mapped("carrier_account_ids")
):
continue
# If we get here, there's no valid account for this carrier
rec.carrier_id = rec._get_default_carrier()
partners = self._get_valid_carrier_partners()
valid_accounts = partners.mapped("carrier_account_ids").filtered(
lambda account: account.delivery_carrier_id == self.carrier_id
)
return bool(valid_accounts)
def _get_default_carrier(self):
self.ensure_one()
@ -253,106 +231,58 @@ class CarrierAccountMixin(models.AbstractModel):
)
def _compute_valid_carrier_account_ids(self):
for rec in self:
match rec.delivery_billing_mode:
case "collect":
rec.valid_carrier_account_ids = (
(rec.recipient_id | rec.recipient_id.commercial_partner_id)
.mapped("carrier_account_ids")
.filtered(
lambda account: account.delivery_carrier_id
== rec.carrier_id
)
)
case "third party":
rec.valid_carrier_account_ids = self.env[
"delivery.carrier.account"
].search(
[
("delivery_carrier_id", "=", rec.carrier_id.id),
(
"partner_id",
"not in",
[
rec.sender_id.id,
rec.sender_id.commercial_partner_id.id,
rec.recipient_id.id,
rec.recipient_id.commercial_partner_id.id,
],
),
]
)
case "prepaid" | "ppc" | "no charge":
rec.valid_carrier_account_ids = (
(rec.sender_id | rec.sender_id.commercial_partner_id)
.mapped("carrier_account_ids")
.filtered(
lambda account: account.delivery_carrier_id
== rec.carrier_id
)
)
case _:
rec.valid_carrier_account_ids = self.env[
"delivery.carrier.account"
].search([])
partners = rec._get_valid_carrier_partners()
if rec.carrier_id and partners:
rec.valid_carrier_account_ids = partners.mapped(
"carrier_account_ids"
).filtered(
lambda account: account.delivery_carrier_id == rec.carrier_id
)
else:
rec.valid_carrier_account_ids = partners.mapped("carrier_account_ids")
@api.depends("delivery_billing_mode", "sender_id", "recipient_id")
def _compute_valid_carrier_ids(self):
"""Compute all valid carriers for the current billing mode and partners."""
for rec in self:
partners, is_third_party, invalid_partners = (
rec._get_valid_carrier_partners()
partners = rec._get_valid_carrier_partners()
rec.valid_carrier_ids = partners.mapped(
"carrier_account_ids.delivery_carrier_id"
)
if is_third_party:
# For third party, all carriers with accounts not belonging to sender/recipient are valid
accounts = self.env["delivery.carrier.account"].search(
[
("partner_id", "not in", invalid_partners.ids),
]
)
rec.valid_carrier_ids = accounts.mapped("delivery_carrier_id")
else:
rec.valid_carrier_ids = partners.mapped(
"carrier_account_ids.delivery_carrier_id"
)
def _on_carrier_fields_changed(self):
pass
@api.constrains("delivery_billing_mode", "carrier_id", "carrier_account_id")
def _check_carrier_account(self):
for rec in self:
if rec.carrier_account_id and rec.delivery_billing_mode:
if (
rec.delivery_billing_mode == "collect"
and rec.carrier_account_id
not in (
rec.recipient_id | rec.recipient_id.commercial_partner_id
).carrier_account_ids
):
if (
rec.carrier_account_id
and rec.delivery_billing_mode
and rec.valid_carrier_account_ids # Use the computed field directly
and rec.carrier_account_id not in rec.valid_carrier_account_ids
):
_logger.warning(
"Billing mode: %s, sender: %s (commercial: %s), recipient: %s (commercial: %s), account: %s, id: %s, valid: %s",
rec.delivery_billing_mode,
rec.sender_id.name,
rec.sender_id.commercial_partner_id.name,
rec.recipient_id.name,
rec.recipient_id.commercial_partner_id.name,
rec.carrier_account_id.partner_id.name,
rec.carrier_account_id.id,
rec.valid_carrier_account_ids.ids,
)
if rec.delivery_billing_mode == "collect":
raise UserError(
"Carrier account is not associated with the recipient, but billing mode is collect."
f"Carrier account is not associated with the recipient, but billing mode is collect. Current object: {rec}"
)
elif (
rec.delivery_billing_mode in ["prepaid", "ppc", "no charge"]
and rec.carrier_account_id
not in (
rec.sender_id | rec.sender_id.commercial_partner_id
).carrier_account_ids
):
elif rec.delivery_billing_mode in ["prepaid", "ppc", "no charge"]:
raise UserError(
"Carrier account is not associated with the sender, but billing mode is prepaid, ppc or no charge."
f"Carrier account is not associated with the sender, but billing mode is prepaid, ppc or no charge. Current object: {rec}"
)
elif (
rec.delivery_billing_mode == "third party"
and rec.carrier_account_id
in (
rec.sender_id
| rec.sender_id.commercial_partner_id
| rec.recipient_id
| rec.recipient_id.commercial_partner_id
).carrier_account_ids
):
elif rec.delivery_billing_mode == "third party":
raise UserError(
"Third party carrier account cannot belong to sender or recipient."
f"Third party carrier account cannot belong to sender or recipient. Current object: {rec}"
)

View file

@ -10,7 +10,7 @@ class SalesOrder(models.Model):
recipient_id = fields.Many2one(
comodel_name="res.partner",
related="partner_id",
related="partner_shipping_id",
)
sender_id = fields.Many2one(
comodel_name="res.partner",

View file

@ -1,4 +1,6 @@
from .test_carrier_account_common import TestCarrierAccountCommon
from odoo.tests import Form
from odoo import Command
class TestSalesOrder(TestCarrierAccountCommon):
@ -21,12 +23,13 @@ class TestSalesOrder(TestCarrierAccountCommon):
def test_prepaid_sale_order_line_gets_proper_name(self):
order = self.env["sale.order"].create({"partner_id": self.client_partner.id})
wiz = self._get_shipping_wizard(order)
wiz.carrier_id = self.delivery_carrier_1
wiz.delivery_billing_mode = "prepaid"
with Form(wiz) as form:
form.carrier_id = self.delivery_carrier_2
form.delivery_billing_mode = "prepaid"
wiz.button_confirm()
self.assertEqual(
order.order_line[0].name,
f"{self.delivery_carrier_1.name} [PREPAID]",
f"{self.delivery_carrier_2.name} [PREPAID]",
)
def test_third_party_sale_order_line_gets_proper_name(self):
@ -42,6 +45,37 @@ class TestSalesOrder(TestCarrierAccountCommon):
f"{self.delivery_carrier_1.name} [THIRD PARTY] #{self.third_party_account_1.account_number}",
)
def test_sale_order_shipping_to_third_party_collect(self):
# We create an order where we are shipping to a third party and the billing mode is collect
# This should work, but was previously failing with an error that the carrier account did not belong to the recipient
order = self.env["sale.order"].create(
{
"partner_id": self.client_partner.id,
"partner_shipping_id": self.random_partner.id,
"order_line": [
Command.create(
{"product_id": self.env.ref("product.product_product_4").id}
)
],
}
)
# Confirming the order is important because the sender and recipient need to be in sync
# on the sale order and delivery order. This was the point of failure previously.
order.action_confirm()
wiz = self._get_shipping_wizard(order)
with Form(wiz) as form:
form.carrier_id = self.delivery_carrier_1
form.delivery_billing_mode = "collect"
self.assertIn(self.third_party_account_1, wiz.valid_carrier_account_ids)
wiz.button_confirm()
self.assertEqual(
order.carrier_account_id.partner_id,
self.random_partner,
"The carrier account should belong to the recipient (sale order shipping address)",
)
def _get_shipping_wizard(self, order):
wizard_action = order.action_open_delivery_wizard()
return (

View file

@ -8,16 +8,32 @@ class ChooseDeliveryCarrier(models.TransientModel):
_name = "choose.delivery.carrier"
sender_id = fields.Many2one(related="company_id.partner_id")
recipient_id = fields.Many2one(related="partner_id")
recipient_id = fields.Many2one(related="order_id.partner_shipping_id")
def button_confirm(self):
vals = {}
if self.delivery_billing_mode:
vals.update(delivery_billing_mode=self.delivery_billing_mode)
if self.carrier_account_id:
vals.update(carrier_account_id=self.carrier_account_id)
vals.update(carrier_account_id=self.carrier_account_id.id)
if self.carrier_id:
vals.update(carrier_id=self.carrier_id.id)
self.order_id.with_context(no_carrier_update=True).write(vals)
# Ensure we have a valid carrier account
if (
self.carrier_id
and self.delivery_billing_mode
and not self.carrier_account_id
):
# Force recompute of valid carrier accounts
self._compute_valid_carrier_account_ids()
default_account = self._get_default_carrier_account()
if default_account:
vals.update(carrier_account_id=default_account.id)
# Write values to the order before calling super
if vals:
self.order_id.with_context(no_carrier_update=True).write(vals)
res = super().button_confirm()
return res

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,33 @@
{
"name": "Reception Purchase Total",
"version": "18.0.1.0.0",
"category": "Inventory/Purchase",
"summary": "Display purchase totals on stock reception views",
"description": """
This module adds purchase price totals to stock reception views. Specifically:
* Adds a total amount column to stock move lines in receptions, showing the purchase price * quantity
* Shows a total amount at the bottom of operations and detailed operations tabs in:
- Reception form view
- Batch Transfer form view
* Links stock moves to their original purchase order line prices
Technical Details:
* Extends stock.move to compute total amount based on purchase line price
* Adds computed fields and view inheritance to display totals
* Compatible with standard Odoo purchase and inventory workflows
""",
"author": "Bemade Inc.",
"website": "https://www.bemade.org",
"depends": [
"stock",
"purchase_stock",
],
"data": [
"views/stock_picking_views.xml",
],
"license": "LGPL-3",
"installable": True,
"auto_install": False,
"application": False,
}

View file

@ -0,0 +1,2 @@
from . import stock_move
from . import stock_move_line

View file

@ -0,0 +1,32 @@
from odoo import api, fields, models
class StockMove(models.Model):
_inherit = "stock.move"
purchase_price_unit = fields.Float(
string="Purchase Unit Price",
related="purchase_line_id.price_unit",
digits="Product Price",
readonly=True,
help="Unit price from the related purchase order line",
)
purchase_price_total = fields.Float(
string="Purchase Total",
compute="_compute_purchase_price_total",
help="Total price based on purchase unit price and move quantity",
)
picking_type_code = fields.Selection(
related="picking_id.picking_type_code",
readonly=True,
)
purchase_currency_id = fields.Many2one(
related="purchase_line_id.currency_id",
string="Purchase Currency",
readonly=True,
)
@api.depends("purchase_price_unit", "quantity")
def _compute_purchase_price_total(self):
for move in self:
move.purchase_price_total = move.purchase_price_unit * move.quantity

View file

@ -0,0 +1,34 @@
from odoo import fields, models, api
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
picking_type_code = fields.Selection(
related="picking_id.picking_type_id.code",
string="Operation Type",
store=True,
readonly=True,
)
purchase_price_unit = fields.Float(
string="Purchase Unit Price",
related="move_id.purchase_price_unit",
readonly=True,
help="Unit price from the related purchase order line",
)
purchase_price_total = fields.Float(
string="Purchase Total",
compute="_compute_purchase_price_total",
help="Total price for this move line based on purchase price and quantity",
)
purchase_currency_id = fields.Many2one(
related="move_id.purchase_currency_id",
string="Purchase Currency",
readonly=True,
)
@api.depends("purchase_price_unit", "quantity")
def _compute_purchase_price_total(self):
for line in self:
line.purchase_price_total = line.purchase_price_unit * line.quantity

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Stock Move List View for Batches -->
<record id="view_move_tree_inherit_reception_purchase_total" model="ir.ui.view">
<field name="name">stock.move.tree.inherit.reception.purchase.total</field>
<field name="model">stock.move</field>
<field name="inherit_id" ref="stock_picking_batch.view_picking_move_tree_inherited"/>
<field name="arch" type="xml">
<field name="product_uom" position="after">
<field name="picking_type_code" invisible="1"/>
<field name="purchase_currency_id" invisible="1"/>
<field name="purchase_price_unit" optional="show" invisible="picking_type_code != 'incoming'" widget="monetary" options="{'currency_field': 'purchase_currency_id'}"/>
<field name="purchase_price_total" optional="show" sum="Total Purchase Amount" invisible="picking_type_code != 'incoming'" widget="monetary" options="{'currency_field': 'purchase_currency_id'}"/>
</field>
</field>
</record>
<!-- Stock Move Line List View for Batches -->
<record id="view_move_line_tree_inherit_reception_purchase_total" model="ir.ui.view">
<field name="name">stock.move.line.tree.inherit.reception.purchase.total</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock_picking_batch.view_move_line_tree"/>
<field name="arch" type="xml">
<field name="quantity" position="after">
<field name="picking_type_code" invisible="1"/>
<field name="purchase_currency_id" invisible="1"/>
<field name="purchase_price_unit" optional="show" invisible="picking_type_code != 'incoming'" widget="monetary" options="{'currency_field': 'purchase_currency_id'}"/>
<field name="purchase_price_total" optional="show" sum="Total Purchase Amount" invisible="picking_type_code != 'incoming'" widget="monetary" options="{'currency_field': 'purchase_currency_id'}"/>
</field>
</field>
</record>
<!-- Stock Picking Form View -->
<record id="view_picking_form_inherit_reception_purchase_total" model="ir.ui.view">
<field name="name">stock.picking.form.inherit.reception.purchase.total</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<!-- Add total to operations tab -->
<xpath expr="//page[@name='operations']//field[@name='move_ids_without_package']//list" position="inside">
<field name="picking_type_code" invisible="1"/>
<field name="purchase_currency_id" invisible="1"/>
<field name="purchase_price_unit" optional="show" invisible="picking_type_code != 'incoming'" widget="monetary" options="{'currency_field': 'purchase_currency_id'}"/>
<field name="purchase_price_total" optional="show" sum="Total Purchase Amount" invisible="picking_type_code != 'incoming'" widget="monetary" options="{'currency_field': 'purchase_currency_id'}"/>
</xpath>
</field>
</record>
</odoo>