Merge branch '18.0' of git.bemade.org:bemade/bemade-addons into 18.0
This commit is contained in:
commit
7c5bc93454
17 changed files with 463 additions and 222 deletions
|
|
@ -48,14 +48,20 @@ class BaseAIProviderInstance(models.Model):
|
||||||
api_key = fields.Char(
|
api_key = fields.Char(
|
||||||
string='API Key',
|
string='API Key',
|
||||||
help='API key if required by the provider',
|
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.model
|
||||||
|
def get_default_instance(self):
|
||||||
|
"""Get the default AI provider instance to use.
|
||||||
|
|
||||||
@api.onchange('provider_id')
|
Returns:
|
||||||
def _onchange_provider_id(self):
|
ai.provider.instance: The default instance to use, or raises UserError if none found
|
||||||
if self.provider_id:
|
"""
|
||||||
self.provider_type = self.provider_id.code
|
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(
|
model_ids = fields.One2many(
|
||||||
'ai.model',
|
'ai.model',
|
||||||
|
|
@ -76,6 +82,10 @@ class BaseAIProviderInstance(models.Model):
|
||||||
help='Maximum number of retry attempts for failed API calls'
|
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 = [
|
_sql_constraints = [
|
||||||
('name_uniq',
|
('name_uniq',
|
||||||
'unique(name)',
|
'unique(name)',
|
||||||
|
|
@ -94,4 +104,3 @@ class BaseAIProviderInstance(models.Model):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.provider_type == 'none':
|
if self.provider_type == 'none':
|
||||||
raise UserError(_('Please select a provider type'))
|
raise UserError(_('Please select a provider type'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
'name': 'Ollama Integration',
|
'name': 'Ollama Integration',
|
||||||
'version': '1.0',
|
'version': '1.0.0',
|
||||||
'category': 'Technical',
|
'category': 'Technical',
|
||||||
'summary': 'Integration with Ollama AI models',
|
'summary': 'Integration with Ollama AI models',
|
||||||
'description': """
|
'description': """
|
||||||
|
|
@ -21,6 +21,7 @@ in your Odoo instance. Features include:
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'data/ai_provider_data.xml',
|
'data/ai_provider_data.xml',
|
||||||
|
'data/ai_provider_instance_data.xml',
|
||||||
'views/ollama_stats_views.xml',
|
'views/ollama_stats_views.xml',
|
||||||
'views/ai_provider_instance_views.xml',
|
'views/ai_provider_instance_views.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
|
|
||||||
31
ai_integration_ollama_api/data/ai_provider_instance_data.xml
Normal file
31
ai_integration_ollama_api/data/ai_provider_instance_data.xml
Normal 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>
|
||||||
17
ai_integration_ollama_api/migrations/1.0.0/post-migration.py
Normal file
17
ai_integration_ollama_api/migrations/1.0.0/post-migration.py
Normal 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
|
||||||
|
$$;
|
||||||
|
""")
|
||||||
|
|
@ -36,16 +36,16 @@ class OllamaAIProviderInstance(models.Model):
|
||||||
else:
|
else:
|
||||||
# Clear Ollama-specific fields
|
# Clear Ollama-specific fields
|
||||||
self.update({
|
self.update({
|
||||||
'num_ctx': False,
|
'num_ctx': False, # Context length
|
||||||
'temperature': False,
|
'temperature': False, # Sampling temperature
|
||||||
'top_p': False,
|
'top_p': False, # Nucleus sampling threshold
|
||||||
'top_k': False,
|
'top_k': False, # Top-k sampling threshold
|
||||||
'repeat_penalty': False,
|
'repeat_penalty': False, # Penalty for repeated tokens
|
||||||
'repeat_last_n': False,
|
'repeat_last_n': False, # Number of tokens to consider for repeat penalty
|
||||||
'num_thread': False,
|
'num_thread': False, # Number of CPU threads to use
|
||||||
'num_gpu': False,
|
'num_gpu': False, # Number of GPUs to use
|
||||||
'num_batch': False,
|
'num_batch': False, # Batch size for inference
|
||||||
'model_name': False,
|
'model_name': False, # Model name/path
|
||||||
})
|
})
|
||||||
|
|
||||||
# Override default host for Ollama
|
# Override default host for Ollama
|
||||||
|
|
@ -53,34 +53,6 @@ class OllamaAIProviderInstance(models.Model):
|
||||||
default='http://localhost:11434',
|
default='http://localhost:11434',
|
||||||
help='Ollama server host URL')
|
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):
|
def test_connection(self):
|
||||||
"""Test the connection to the Ollama server.
|
"""Test the connection to the Ollama server.
|
||||||
|
|
||||||
|
|
@ -156,15 +128,26 @@ class OllamaAIProviderInstance(models.Model):
|
||||||
else:
|
else:
|
||||||
self.env['ai.model'].create(vals)
|
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 {
|
return {
|
||||||
'type': 'ir.actions.client',
|
'type': 'ir.actions.act_window',
|
||||||
'tag': 'display_notification',
|
'res_model': 'ai.provider.instance',
|
||||||
'params': {
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
'flags': {
|
||||||
|
'mode': 'readonly',
|
||||||
|
'reload': True, # Force reload
|
||||||
|
},
|
||||||
|
'context': {'notification': {
|
||||||
|
'type': 'success',
|
||||||
'title': _('Success'),
|
'title': _('Success'),
|
||||||
'message': _('Successfully synchronized %d models', len(models)),
|
'message': _('Successfully synchronized %d models', len(models)),
|
||||||
'sticky': False,
|
'sticky': False,
|
||||||
'type': 'success',
|
}}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise UserError(_('Model synchronization failed: %s', str(e)))
|
raise UserError(_('Model synchronization failed: %s', str(e)))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
from odoo import models, fields, api, _
|
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):
|
class OllamaProviderMixin(models.AbstractModel):
|
||||||
"""Mixin model that provides Ollama-specific configuration parameters.
|
"""Mixin model that provides Ollama-specific configuration parameters.
|
||||||
|
|
@ -27,7 +33,7 @@ class OllamaProviderMixin(models.AbstractModel):
|
||||||
string='Model Name',
|
string='Model Name',
|
||||||
help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)',
|
help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)',
|
||||||
required=True,
|
required=True,
|
||||||
default='llama2')
|
default='deepseek-r1:32b')
|
||||||
|
|
||||||
# Context Window Configuration
|
# Context Window Configuration
|
||||||
num_ctx = fields.Integer(
|
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 '
|
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. '
|
'the model to access more historical information but requires more memory. '
|
||||||
'Range: [0 - 32768].',
|
'Range: [0 - 32768].',
|
||||||
default=4096)
|
default=8192)
|
||||||
|
|
||||||
# Generation Parameters
|
# Generation Parameters
|
||||||
temperature = fields.Float(
|
temperature = fields.Float(
|
||||||
|
|
@ -43,39 +49,7 @@ class OllamaProviderMixin(models.AbstractModel):
|
||||||
help='Controls randomness in the output. Higher values make the output more random, '
|
help='Controls randomness in the output. Higher values make the output more random, '
|
||||||
'while lower values make it more focused and deterministic. '
|
'while lower values make it more focused and deterministic. '
|
||||||
'Range: [0.0 - 2.0]',
|
'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]',
|
|
||||||
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',
|
|
||||||
help='Limits the cumulative probability of tokens to sample from. Only the top K '
|
|
||||||
'most likely tokens are considered for sampling at each step. '
|
|
||||||
'Range: [1 - 100].',
|
|
||||||
default=40)
|
|
||||||
|
|
||||||
top_p = fields.Float(
|
top_p = fields.Float(
|
||||||
string='Top P (Nucleus Sampling)',
|
string='Top P (Nucleus Sampling)',
|
||||||
|
|
@ -84,6 +58,13 @@ class OllamaProviderMixin(models.AbstractModel):
|
||||||
'Range: [0.0 - 1.0].',
|
'Range: [0.0 - 1.0].',
|
||||||
default=0.9)
|
default=0.9)
|
||||||
|
|
||||||
|
top_k = fields.Integer(
|
||||||
|
string='Top K',
|
||||||
|
help='Limits the cumulative probability of tokens to sample from. Only the top K '
|
||||||
|
'most likely tokens are considered for sampling at each step. '
|
||||||
|
'Range: [1 - 100].',
|
||||||
|
default=40)
|
||||||
|
|
||||||
min_p = fields.Float(
|
min_p = fields.Float(
|
||||||
string='Min P',
|
string='Min P',
|
||||||
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
|
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,
|
default=1.1,
|
||||||
digits=(3, 2))
|
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(
|
repeat_last_n = fields.Integer(
|
||||||
string='Repeat Last N',
|
string='Repeat Last N',
|
||||||
help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.',
|
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
|
# Advanced Generation Parameters
|
||||||
seed = fields.Integer(
|
seed = fields.Integer(
|
||||||
|
|
@ -174,6 +242,7 @@ class OllamaProviderMixin(models.AbstractModel):
|
||||||
"""Get Ollama-specific options for API calls."""
|
"""Get Ollama-specific options for API calls."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
options = {
|
options = {
|
||||||
|
'model': self.model_name,
|
||||||
'temperature': self.temperature,
|
'temperature': self.temperature,
|
||||||
'num_ctx': self.num_ctx,
|
'num_ctx': self.num_ctx,
|
||||||
'num_predict': self.num_predict,
|
'num_predict': self.num_predict,
|
||||||
|
|
@ -192,6 +261,7 @@ class OllamaProviderMixin(models.AbstractModel):
|
||||||
'num_keep': self.num_keep,
|
'num_keep': self.num_keep,
|
||||||
'tfs_z': self.tfs_z,
|
'tfs_z': self.tfs_z,
|
||||||
'skip_special_tokens': self.skip_special_tokens,
|
'skip_special_tokens': self.skip_special_tokens,
|
||||||
|
'stream': False
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.stop_sequences:
|
if self.stop_sequences:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
#
|
#
|
||||||
{
|
{
|
||||||
"name": "Carrier Accounts by Partner",
|
"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",
|
"summary": "Add one or many carrier accounts per partner",
|
||||||
"category": "Delivery",
|
"category": "Delivery",
|
||||||
"author": "Bemade Inc.",
|
"author": "Bemade Inc.",
|
||||||
|
|
|
||||||
|
|
@ -100,32 +100,24 @@ class CarrierAccountMixin(models.AbstractModel):
|
||||||
Returns:
|
Returns:
|
||||||
Tuple containing:
|
Tuple containing:
|
||||||
- partners: recordset of partners that can have valid accounts
|
- 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()
|
self.ensure_one()
|
||||||
invalid_partners = self.env["res.partner"].browse()
|
invalid_partners = self.env["res.partner"]
|
||||||
|
|
||||||
match self.delivery_billing_mode:
|
match self.delivery_billing_mode:
|
||||||
case "collect":
|
case "collect":
|
||||||
return (
|
return self.recipient_id | self.recipient_id.commercial_partner_id
|
||||||
self.recipient_id | self.recipient_id.commercial_partner_id,
|
|
||||||
False,
|
|
||||||
invalid_partners,
|
|
||||||
)
|
|
||||||
case "prepaid" | "ppc" | "no charge":
|
case "prepaid" | "ppc" | "no charge":
|
||||||
return (
|
return self.sender_id | self.sender_id.commercial_partner_id
|
||||||
self.sender_id | self.sender_id.commercial_partner_id,
|
|
||||||
False,
|
|
||||||
invalid_partners,
|
|
||||||
)
|
|
||||||
case "third party":
|
case "third party":
|
||||||
invalid_partners = (
|
invalid_partners = (
|
||||||
self.recipient_id | self.recipient_id.commercial_partner_id
|
self.recipient_id | self.recipient_id.commercial_partner_id
|
||||||
) | (self.sender_id | self.sender_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 _:
|
case _:
|
||||||
return self.env["res.partner"].browse(), False, invalid_partners
|
return self.env["res.partner"]
|
||||||
|
|
||||||
@api.depends(
|
@api.depends(
|
||||||
"sender_id",
|
"sender_id",
|
||||||
|
|
@ -134,40 +126,26 @@ class CarrierAccountMixin(models.AbstractModel):
|
||||||
)
|
)
|
||||||
def _compute_carrier_id(self):
|
def _compute_carrier_id(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
# Only set carrier if not already set
|
|
||||||
if not rec.carrier_id:
|
if not rec.carrier_id:
|
||||||
rec.carrier_id = rec._get_default_carrier()
|
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
|
def _has_valid_account_for_carrier(self):
|
||||||
if rec.delivery_billing_mode:
|
"""Check if there's a valid account for the current carrier and billing mode."""
|
||||||
partners, is_third_party, invalid_partners = (
|
self.ensure_one()
|
||||||
rec._get_valid_carrier_partners()
|
if not self.carrier_id or not self.delivery_billing_mode:
|
||||||
)
|
return False
|
||||||
|
|
||||||
if is_third_party:
|
partners = self._get_valid_carrier_partners()
|
||||||
# For third party, check if there's any account for this carrier
|
valid_accounts = partners.mapped("carrier_account_ids").filtered(
|
||||||
# that doesn't belong to sender or recipient
|
lambda account: account.delivery_carrier_id == self.carrier_id
|
||||||
has_valid_account = bool(
|
)
|
||||||
self.env["delivery.carrier.account"].search_count(
|
return bool(valid_accounts)
|
||||||
[
|
|
||||||
("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()
|
|
||||||
|
|
||||||
def _get_default_carrier(self):
|
def _get_default_carrier(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
@ -253,106 +231,58 @@ class CarrierAccountMixin(models.AbstractModel):
|
||||||
)
|
)
|
||||||
def _compute_valid_carrier_account_ids(self):
|
def _compute_valid_carrier_account_ids(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
match rec.delivery_billing_mode:
|
partners = rec._get_valid_carrier_partners()
|
||||||
case "collect":
|
if rec.carrier_id and partners:
|
||||||
rec.valid_carrier_account_ids = (
|
rec.valid_carrier_account_ids = partners.mapped(
|
||||||
(rec.recipient_id | rec.recipient_id.commercial_partner_id)
|
"carrier_account_ids"
|
||||||
.mapped("carrier_account_ids")
|
).filtered(
|
||||||
.filtered(
|
lambda account: account.delivery_carrier_id == rec.carrier_id
|
||||||
lambda account: account.delivery_carrier_id
|
)
|
||||||
== rec.carrier_id
|
else:
|
||||||
)
|
rec.valid_carrier_account_ids = partners.mapped("carrier_account_ids")
|
||||||
)
|
|
||||||
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([])
|
|
||||||
|
|
||||||
@api.depends("delivery_billing_mode", "sender_id", "recipient_id")
|
@api.depends("delivery_billing_mode", "sender_id", "recipient_id")
|
||||||
def _compute_valid_carrier_ids(self):
|
def _compute_valid_carrier_ids(self):
|
||||||
"""Compute all valid carriers for the current billing mode and partners."""
|
"""Compute all valid carriers for the current billing mode and partners."""
|
||||||
for rec in self:
|
for rec in self:
|
||||||
partners, is_third_party, invalid_partners = (
|
partners = rec._get_valid_carrier_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):
|
def _on_carrier_fields_changed(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@api.constrains("delivery_billing_mode", "carrier_id", "carrier_account_id")
|
@api.constrains("delivery_billing_mode", "carrier_id", "carrier_account_id")
|
||||||
def _check_carrier_account(self):
|
def _check_carrier_account(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if rec.carrier_account_id and rec.delivery_billing_mode:
|
if (
|
||||||
if (
|
rec.carrier_account_id
|
||||||
rec.delivery_billing_mode == "collect"
|
and rec.delivery_billing_mode
|
||||||
and rec.carrier_account_id
|
and rec.valid_carrier_account_ids # Use the computed field directly
|
||||||
not in (
|
and rec.carrier_account_id not in rec.valid_carrier_account_ids
|
||||||
rec.recipient_id | rec.recipient_id.commercial_partner_id
|
):
|
||||||
).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(
|
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 (
|
elif rec.delivery_billing_mode in ["prepaid", "ppc", "no charge"]:
|
||||||
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
|
|
||||||
):
|
|
||||||
raise UserError(
|
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 (
|
elif rec.delivery_billing_mode == "third party":
|
||||||
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
|
|
||||||
):
|
|
||||||
raise UserError(
|
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}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class SalesOrder(models.Model):
|
||||||
|
|
||||||
recipient_id = fields.Many2one(
|
recipient_id = fields.Many2one(
|
||||||
comodel_name="res.partner",
|
comodel_name="res.partner",
|
||||||
related="partner_id",
|
related="partner_shipping_id",
|
||||||
)
|
)
|
||||||
sender_id = fields.Many2one(
|
sender_id = fields.Many2one(
|
||||||
comodel_name="res.partner",
|
comodel_name="res.partner",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
from .test_carrier_account_common import TestCarrierAccountCommon
|
from .test_carrier_account_common import TestCarrierAccountCommon
|
||||||
|
from odoo.tests import Form
|
||||||
|
from odoo import Command
|
||||||
|
|
||||||
|
|
||||||
class TestSalesOrder(TestCarrierAccountCommon):
|
class TestSalesOrder(TestCarrierAccountCommon):
|
||||||
|
|
@ -21,12 +23,13 @@ class TestSalesOrder(TestCarrierAccountCommon):
|
||||||
def test_prepaid_sale_order_line_gets_proper_name(self):
|
def test_prepaid_sale_order_line_gets_proper_name(self):
|
||||||
order = self.env["sale.order"].create({"partner_id": self.client_partner.id})
|
order = self.env["sale.order"].create({"partner_id": self.client_partner.id})
|
||||||
wiz = self._get_shipping_wizard(order)
|
wiz = self._get_shipping_wizard(order)
|
||||||
wiz.carrier_id = self.delivery_carrier_1
|
with Form(wiz) as form:
|
||||||
wiz.delivery_billing_mode = "prepaid"
|
form.carrier_id = self.delivery_carrier_2
|
||||||
|
form.delivery_billing_mode = "prepaid"
|
||||||
wiz.button_confirm()
|
wiz.button_confirm()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
order.order_line[0].name,
|
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):
|
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}",
|
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):
|
def _get_shipping_wizard(self, order):
|
||||||
wizard_action = order.action_open_delivery_wizard()
|
wizard_action = order.action_open_delivery_wizard()
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,32 @@ class ChooseDeliveryCarrier(models.TransientModel):
|
||||||
_name = "choose.delivery.carrier"
|
_name = "choose.delivery.carrier"
|
||||||
|
|
||||||
sender_id = fields.Many2one(related="company_id.partner_id")
|
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):
|
def button_confirm(self):
|
||||||
vals = {}
|
vals = {}
|
||||||
if self.delivery_billing_mode:
|
if self.delivery_billing_mode:
|
||||||
vals.update(delivery_billing_mode=self.delivery_billing_mode)
|
vals.update(delivery_billing_mode=self.delivery_billing_mode)
|
||||||
if self.carrier_account_id:
|
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:
|
if self.carrier_id:
|
||||||
vals.update(carrier_id=self.carrier_id.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()
|
res = super().button_confirm()
|
||||||
return res
|
return res
|
||||||
|
|
|
||||||
1
reception_purchase_total/__init__.py
Normal file
1
reception_purchase_total/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
33
reception_purchase_total/__manifest__.py
Normal file
33
reception_purchase_total/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
2
reception_purchase_total/models/__init__.py
Normal file
2
reception_purchase_total/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import stock_move
|
||||||
|
from . import stock_move_line
|
||||||
32
reception_purchase_total/models/stock_move.py
Normal file
32
reception_purchase_total/models/stock_move.py
Normal 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
|
||||||
34
reception_purchase_total/models/stock_move_line.py
Normal file
34
reception_purchase_total/models/stock_move_line.py
Normal 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
|
||||||
48
reception_purchase_total/views/stock_picking_views.xml
Normal file
48
reception_purchase_total/views/stock_picking_views.xml
Normal 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>
|
||||||
Loading…
Reference in a new issue