diff --git a/ai_integration/models/ai_provider_instance.py b/ai_integration/models/ai_provider_instance.py
index ae1035d..121c31f 100644
--- a/ai_integration/models/ai_provider_instance.py
+++ b/ai_integration/models/ai_provider_instance.py
@@ -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'))
-
diff --git a/ai_integration_ollama_api/__manifest__.py b/ai_integration_ollama_api/__manifest__.py
index a724267..e119cbc 100644
--- a/ai_integration_ollama_api/__manifest__.py
+++ b/ai_integration_ollama_api/__manifest__.py
@@ -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',
diff --git a/ai_integration_ollama_api/data/ai_provider_instance_data.xml b/ai_integration_ollama_api/data/ai_provider_instance_data.xml
new file mode 100644
index 0000000..7097bb4
--- /dev/null
+++ b/ai_integration_ollama_api/data/ai_provider_instance_data.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Ollama Local
+ ollama
+ http://localhost:11434
+ llama3.2
+ 0.7
+ 40
+ 0.9
+ 1.1
+ 4096
+ 1024
+ 0.05
+ 64
+ 0
+ 1
+ 8
+ 0
+ 5.0
+ 0.1
+ 8
+ 0
+ 1.0
+
+
+
+
+
diff --git a/ai_integration_ollama_api/migrations/1.0.0/post-migration.py b/ai_integration_ollama_api/migrations/1.0.0/post-migration.py
new file mode 100644
index 0000000..0998710
--- /dev/null
+++ b/ai_integration_ollama_api/migrations/1.0.0/post-migration.py
@@ -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
+ $$;
+ """)
diff --git a/ai_integration_ollama_api/models/ai_provider_instance.py b/ai_integration_ollama_api/models/ai_provider_instance.py
index dd9d041..466ceb3 100644
--- a/ai_integration_ollama_api/models/ai_provider_instance.py
+++ b/ai_integration_ollama_api/models/ai_provider_instance.py
@@ -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)))
diff --git a/ai_integration_ollama_api/models/ollama_provider_mixin.py b/ai_integration_ollama_api/models/ollama_provider_mixin.py
index b48399b..c50a4fe 100644
--- a/ai_integration_ollama_api/models/ollama_provider_mixin.py
+++ b/ai_integration_ollama_api/models/ollama_provider_mixin.py
@@ -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:
diff --git a/delivery_carrier_partner_account/__manifest__.py b/delivery_carrier_partner_account/__manifest__.py
index 8aae19a..ac5261a 100644
--- a/delivery_carrier_partner_account/__manifest__.py
+++ b/delivery_carrier_partner_account/__manifest__.py
@@ -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.",
diff --git a/delivery_carrier_partner_account/models/carrier_account_mixin.py b/delivery_carrier_partner_account/models/carrier_account_mixin.py
index 62b809b..52168af 100644
--- a/delivery_carrier_partner_account/models/carrier_account_mixin.py
+++ b/delivery_carrier_partner_account/models/carrier_account_mixin.py
@@ -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}"
)
diff --git a/delivery_carrier_partner_account/models/sales_order.py b/delivery_carrier_partner_account/models/sales_order.py
index 5f31c6d..be8ef38 100644
--- a/delivery_carrier_partner_account/models/sales_order.py
+++ b/delivery_carrier_partner_account/models/sales_order.py
@@ -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",
diff --git a/delivery_carrier_partner_account/tests/test_sale_order.py b/delivery_carrier_partner_account/tests/test_sale_order.py
index 3002678..c8c1a90 100644
--- a/delivery_carrier_partner_account/tests/test_sale_order.py
+++ b/delivery_carrier_partner_account/tests/test_sale_order.py
@@ -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 (
diff --git a/delivery_carrier_partner_account/wizard/choose_delivery_carrier.py b/delivery_carrier_partner_account/wizard/choose_delivery_carrier.py
index 29926b1..3804885 100644
--- a/delivery_carrier_partner_account/wizard/choose_delivery_carrier.py
+++ b/delivery_carrier_partner_account/wizard/choose_delivery_carrier.py
@@ -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
diff --git a/reception_purchase_total/__init__.py b/reception_purchase_total/__init__.py
new file mode 100644
index 0000000..0650744
--- /dev/null
+++ b/reception_purchase_total/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/reception_purchase_total/__manifest__.py b/reception_purchase_total/__manifest__.py
new file mode 100644
index 0000000..dee7ca0
--- /dev/null
+++ b/reception_purchase_total/__manifest__.py
@@ -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,
+}
diff --git a/reception_purchase_total/models/__init__.py b/reception_purchase_total/models/__init__.py
new file mode 100644
index 0000000..f800274
--- /dev/null
+++ b/reception_purchase_total/models/__init__.py
@@ -0,0 +1,2 @@
+from . import stock_move
+from . import stock_move_line
diff --git a/reception_purchase_total/models/stock_move.py b/reception_purchase_total/models/stock_move.py
new file mode 100644
index 0000000..8ad57a4
--- /dev/null
+++ b/reception_purchase_total/models/stock_move.py
@@ -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
diff --git a/reception_purchase_total/models/stock_move_line.py b/reception_purchase_total/models/stock_move_line.py
new file mode 100644
index 0000000..24669ac
--- /dev/null
+++ b/reception_purchase_total/models/stock_move_line.py
@@ -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
diff --git a/reception_purchase_total/views/stock_picking_views.xml b/reception_purchase_total/views/stock_picking_views.xml
new file mode 100644
index 0000000..2ca1f28
--- /dev/null
+++ b/reception_purchase_total/views/stock_picking_views.xml
@@ -0,0 +1,48 @@
+
+
+
+
+ stock.move.tree.inherit.reception.purchase.total
+ stock.move
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stock.move.line.tree.inherit.reception.purchase.total
+ stock.move.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stock.picking.form.inherit.reception.purchase.total
+ stock.picking
+
+
+
+
+
+
+
+
+
+
+
+