Compare commits

..

52 commits

Author SHA1 Message Date
mathis
72ee664967 Fix Work Order Report translations by ensuring proper translation tags and context 2025-06-26 14:29:22 -04:00
mathis
8775cec248 Fix timezones printing in UTC instead of client`s timezone in FSM work orders 2025-06-17 10:27:14 -04:00
mathis
b90466786d Fixed FSM work orders printing with UTC times 2025-06-13 11:13:08 -04:00
mathis
67fcffb37a Durpro inheritance mixin editing and work order print depending on language of work order contact 2025-06-13 09:35:32 -04:00
Marc Durepos
20e4f6c8b3 caldav_sync: v0.8.0 - disable notifications when polling server
- Disable sending of notification emails when events are created or updated
  in Odoo during a CalDAV server synchronization.
- General code cleanup with improved type hints.
2025-06-10 10:13:45 -04:00
Marc Durepos
401818765a confirm_many2one_create: simplify js 2025-05-16 11:01:56 -04:00
Marc Durepos
d1e97e3448 fix confirm_many2one_create JS 2025-05-16 10:57:10 -04:00
Marc Durepos
0b5a791e1f add tests for caldav_sync, new module preamble_on_quotation 2025-05-16 09:52:07 -04:00
Marc Durepos
4b19f8262e caldav_sync: further fixes for organizer issues 2025-05-16 09:52:07 -04:00
Marc Durepos
3ebadcb969 caldav_sync: fix for incorrect organizer setting
Prior to this fix, the organizer on events was being incorrectly set to
the database's admin user in some cases, when synchronizing an event
from the CalDAV server.
2025-05-16 09:52:07 -04:00
Marc Durepos
db10db822c caldav_sync bug fixes:
* Fixed an issue where accepting events from an external organizer would send emails
  to the all event attendees upon synchronization.
* Corrected the data model for events where multiple Odoo users are attendees for the
  same event. Now, only one event is created in Odoo, with all the attendees on the
  same event. This conforms to Odoo's existing calendar event data model.
* Updated the setting of the organizer field (user_id) on the calendar events upon
  synchronization. Previously, the organizer field was always set to the current user
  whose events were being synchronized. Now, it is set based on the ORGANIZER parameter
  of the iCalendar event, if present. If not present, it defaults to the current synchronizing
  user. In the case that the organizer is external, the user_id field is left blank.
2025-05-16 09:51:42 -04:00
Marc Durepos
9fb835db34 [FIX] bemade_fsm: correctly set partner on subtasks for visits
Prior to this commit, subtasks that were added (from task templates) as
part of a visit (from sales orders), were having their partner_id set to
False by a built-in Odoo _compute_partner_id method.

This fix (and previous commits) introduce a test that correctly failed
when this behaviour was broken as well as modifications to the behaviour
of sale order lines and tasks to ensure that all tasks created from a
sale order for an FSM project correctly get their partner_id set to the
sale order's delivery address.
2025-04-24 08:49:33 -04:00
Marc Durepos
512bf58da8 Merge commit '873a7ba7' into 17.0 2025-04-24 08:34:24 -04:00
Marc Durepos
873a7ba71a bemade_fsm: add a failing test to demonstrate partner assignment bug 2025-04-24 08:34:03 -04:00
Marc Durepos
b19da588cd bemade_fsm: correct partner_id on task to use partner_shipping_id from sale order 2025-04-24 08:30:16 -04:00
Marc Durepos
ab60babd7d fix division by zero in vendor pricelist 2025-04-23 15:05:48 -04:00
Marc Durepos
055ceede53 fsm_visit_confirmation: only send email for top-level tasks 2025-02-27 14:52:16 -05:00
Marc Durepos
9859a3cf96 fsm_visit_confirmation: don't auto-delete email from chatter 2025-02-27 14:39:31 -05:00
Marc Durepos
57b401ca1a fsm_visit_confirmation: ensure token before sending email 2025-02-27 14:36:19 -05:00
Marc Durepos
2322dbaae1 fsm_visit_confirmation: completely separated from ratings now 2025-02-26 21:25:37 -05:00
Marc Durepos
81a4a904c7 fsm_visit_confirmation: translate template, move away from ratings 2025-02-26 20:58:14 -05:00
Marc Durepos
95a95298bb fsm_visit_confirmation: new module good to go 2025-02-26 14:52:17 -05:00
Marc Durepos
f9f32aab34 new module fsm_visit_confirmation 2025-02-26 10:12:33 -05:00
Marc Durepos
65fbcb9b33 caldav_sync bug fixes:
* Fixed an issue where accepting events from an external organizer would send emails
  to the all event attendees upon synchronization.
* Corrected the data model for events where multiple Odoo users are attendees for the
  same event. Now, only one event is created in Odoo, with all the attendees on the
  same event. This conforms to Odoo's existing calendar event data model.
* Updated the setting of the organizer field (user_id) on the calendar events upon
  synchronization. Previously, the organizer field was always set to the current user
  whose events were being synchronized. Now, it is set based on the ORGANIZER parameter
  of the iCalendar event, if present. If not present, it defaults to the current synchronizing
  user. In the case that the organizer is external, the user_id field is left blank.
2025-02-07 12:59:29 -05:00
Benoît Vézina
799e944112 fix and validated date_deadline 2025-02-06 10:19:48 -05:00
Benoît Vézina
7ab530bd29 Fixe date deadline cascade 2025-02-05 16:13:34 -05:00
xtremxpert
b1a625a0e4 wip 2025-02-05 13:14:42 -05:00
Marc Durepos
45c1bdf440 bemade_fsm: fix a bug where changing the delivery address on a canceled/re-drafted SO screwed up subtasks 2025-01-27 11:00:09 -05:00
Marc Durepos
370ebae08b bemade_fsm: Fix broken tests 2025-01-27 08:42:37 -05:00
Marc Durepos
d334b63696 bemade_fsm: use commercial partner id to calculate valid equipments on SO 2025-01-17 10:39:35 -05:00
Marc Durepos
424e9e5159 nuke_mid_task: Get rid of unnecessary security folder 2025-01-13 13:12:06 +00:00
Benoît Vézina
1db303d0a5 nuke task 2025-01-10 11:38:33 -05:00
xtremxpert
5050839056 Unifi to test 2025-01-06 08:02:03 -05:00
Benoît Vézina
010752b01f itch work? 2025-01-02 08:06:56 -05:00
Benoît Vézina
d644841f99 js work 2024-12-23 11:40:07 -05:00
xtremxpert
3ba182fe0e fick js 2024-12-19 11:20:44 -05:00
Benoît Vézina
b06e9c4569 dawn js 2024-12-19 10:16:30 -05:00
Marc Durepos
f3a7072314 price_update_notifications: add mail.activity.mixin to notices 2024-12-17 16:11:06 -05:00
Benoît Vézina
57c938bb8d itch 2024-12-17 15:40:14 -05:00
xtremxpert
fa4f1b2b19 Merge branch '17.0' of git.bemade.org:bemade/bemade-addons into 17.0 2024-12-17 12:51:57 -05:00
xtremxpert
536f10b61e itch et unifi 2024-12-17 12:51:45 -05:00
Marc Durepos
f6ab94e0b3 caldav_sync: add SELF_WRITEABLE_FIELDS for users 2024-12-17 10:43:10 -05:00
Benoît Vézina
9578594628 update 2024-12-14 06:53:23 -05:00
Marc Durepos
5a97745d42 [FIX] set timezone on context if empty in event and user
Prior to this commit, synchronizing an event to the CalDAV server could
break in the _add_event_dates method if both self.event_tz and
self.env.user.tz were empty (False). This should be a rare occurrence,
but adding a fallback of self._context.get('tz') should help.
2024-12-12 08:45:11 -05:00
Marc Durepos
a0ff56ade2 fix product_template_id field ref on sale order line for price update notif 2024-12-10 09:34:10 -05:00
Marc Durepos
8ce49de715 fix to translation for price update notice 2024-12-10 09:27:43 -05:00
Marc Durepos
3f6f452fe1 fix to translation for price update notice 2024-12-10 09:26:04 -05:00
Marc Durepos
8c1758dacd Merge commit '58e49f0d' into 17.0 2024-12-10 09:12:43 -05:00
Marc Durepos
58e49f0db5 bug fix for price update 2024-12-10 09:12:26 -05:00
xtremxpert
5b18adb083 Merge branch '17.0' of git.bemade.org:bemade/bemade-addons into 17.0 2024-12-09 09:36:06 -05:00
xtremxpert
fb057ada9a more on that 2024-12-09 09:35:56 -05:00
xtremxpert
7783b04e73 wip 2024-12-02 15:12:08 -05:00
1290 changed files with 20398 additions and 102949 deletions

View file

@ -37,12 +37,12 @@ repos:
language: fail
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
- repo: https://github.com/OCA/odoo-pre-commit-hooks
rev: v0.1.6
rev: v0.0.25
hooks:
- id: oca-checks-odoo-module
- id: oca-checks-po
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
rev: v2.7.1
hooks:
- id: prettier
name: prettier (with plugin-xml)
@ -53,7 +53,7 @@ repos:
- --plugin=@prettier/plugin-xml
files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.33.0
rev: v8.24.0
hooks:
- id: eslint
verbose: true
@ -61,7 +61,7 @@ repos:
- --color
- --fix
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v4.3.0
hooks:
- id: trailing-whitespace
# exclude autogenerated files
@ -83,7 +83,7 @@ repos:
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: https://github.com/OCA/pylint-odoo
rev: v9.3.14
rev: v9.1.2
hooks:
- id: pylint_odoo
name: pylint with optional checks

View file

@ -1,6 +1,6 @@
{
"name": "Account Credit Hold",
"version": "18.0.1.1.1",
"version": "17.0.1.1.1",
"summary": "Allows setting clients on credit hold, blocking the ability confirm a new sales order.",
"category": "Accounting/Accounting",
"author": "Bemade Inc.",

View file

@ -4,13 +4,9 @@ from odoo import models, fields, api, _
class FollowUpReport(models.AbstractModel):
_inherit = 'account.followup.report'
def _get_followup_report_options(self, partner, options=None):
"""
Override to include credit hold information in followup report options.
"""
res = super()._get_followup_report_options(partner, options)
def _get_line_info(self, followup_line):
res = super()._get_line_info(followup_line)
res.update({
'credit_hold': partner.followup_line_id.account_hold if partner.followup_line_id else False,
'partner_on_hold': partner.on_hold
'credit_hold': followup_line.account_hold
})
return res

View file

@ -26,21 +26,24 @@ class Partner(models.Model):
compute_sudo=True,
)
@api.depends("postpone_hold_until", "hold_bg", "commercial_partner_id.hold_bg")
@api.depends("postpone_hold_until", "hold_bg")
def _compute_on_hold(self):
# manually re-compute hold_bg since followup_status doesn't get updated in Python but gets recalculated
# by an SQL query every time
self._compute_hold_bg()
for rec in self:
# If the parent company is on hold, so are all its sub-contacts and subsidiaries
if rec.commercial_partner_id != rec and rec.commercial_partner_id.hold_bg:
if not (rec.commercial_partner_id.postpone_hold_until and rec.commercial_partner_id.postpone_hold_until > date.today()):
rec.on_hold = True
continue
if rec.commercial_partner_id and rec.commercial_partner_id.on_hold:
rec.on_hold = True
return
# If there is no parent company or the parent is not on hold, we compute for ourselves
if rec.hold_bg and not (
rec.postpone_hold_until and rec.postpone_hold_until > date.today()
):
rec.on_hold = True
else:
if rec.on_hold:
rec.message_post(_("Credit hold lifted."))
rec.on_hold = False
@api.autovacuum
@ -58,13 +61,12 @@ class Partner(models.Model):
rec.hold_bg = False
rec.message_post(body=_("Credit hold lifted."))
@api.model
def _get_first_followup_level(self):
return self.env["account_followup.followup.line"].search(
[("company_id", "parent_of", self.env.company.id)],
order="delay asc",
limit=1,
)
def _execute_followup_partner(self, options=None):
res = super()._execute_followup_partner(options)
if self.followup_status == "in_need_of_action":
if self.followup_line_id.account_hold:
self.action_credit_hold()
return res
@api.depends("followup_status", "followup_line_id")
def _compute_hold_bg(self):
@ -76,33 +78,3 @@ class Partner(models.Model):
rec.hold_bg = False
else:
rec.hold_bg = prev_hold_bg
def _get_followup_report(self, options):
# Override to prevent hanging on PDF generation
# Just set minimal required options without generating the report
options.setdefault('attachment_ids', [])
options['report_attachment_id'] = False
def _execute_followup_partner(self, options=None):
# Check if we need to place on credit hold before expensive operations
should_hold = (
self.followup_status == "in_need_of_action" and
self.followup_line_id and
hasattr(self.followup_line_id, 'account_hold') and
self.followup_line_id.account_hold
)
# If this is just for credit hold and we don't need reports/emails, skip heavy operations
if options and options.get('credit_hold_only'):
if should_hold:
self.action_credit_hold()
return should_hold
# Otherwise run the full followup process
res = super()._execute_followup_partner(options)
# Apply credit hold after successful followup execution
if should_hold:
self.action_credit_hold()
return res

View file

@ -9,8 +9,9 @@ class SaleOrder(models.Model):
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.on_hold")
@api.depends('client_on_hold')
def action_confirm(self):
if any(self.mapped('client_on_hold')):
raise UserError(_("This client is on credit hold. No new orders can be confirmed until past-due invoices "
"are paid or the accounting team postpones the hold."))
return super().action_confirm()
super().action_confirm()

View file

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

View file

@ -1,342 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta
from odoo.tests import common, tagged, Form
from odoo.exceptions import UserError
@tagged("post_install", "-at_install")
class TestAccountCreditHold(common.TransactionCase):
def setUp(self):
super().setUp()
# Create test partner
self.partner = self.env["res.partner"].create(
{
"name": "Test Customer",
"is_company": True,
"customer_rank": 1,
"email": "test@example.com",
}
)
# Try to find existing followup lines or create new ones with unique delays
self.followup_line = self.env["account_followup.followup.line"].search(
[("delay", "=", 15), ("company_id", "=", self.env.company.id)], limit=1
)
if not self.followup_line:
# Find a unique delay value
existing_delays = (
self.env["account_followup.followup.line"]
.search([("company_id", "=", self.env.company.id)])
.mapped("delay")
)
delay = 15
while delay in existing_delays:
delay += 1
self.followup_line = self.env["account_followup.followup.line"].create(
{
"name": "First Reminder",
"delay": delay,
"account_hold": True,
"send_email": True,
"company_id": self.env.company.id,
}
)
else:
# Update existing line to have account_hold
self.followup_line.account_hold = True
# Create followup line without credit hold
self.followup_line_no_hold = self.env["account_followup.followup.line"].search(
[("delay", "=", 30), ("company_id", "=", self.env.company.id)], limit=1
)
if not self.followup_line_no_hold:
# Find a unique delay value
existing_delays = (
self.env["account_followup.followup.line"]
.search([("company_id", "=", self.env.company.id)])
.mapped("delay")
)
delay = 30
while delay in existing_delays:
delay += 1
self.followup_line_no_hold = self.env[
"account_followup.followup.line"
].create(
{
"name": "Second Reminder",
"delay": delay,
"account_hold": False,
"send_email": True,
"company_id": self.env.company.id,
}
)
else:
# Update existing line to not have account_hold
self.followup_line_no_hold.account_hold = False
def test_credit_hold_basic_functionality(self):
"""Test basic credit hold functionality"""
# Initially partner should not be on hold
self.assertFalse(self.partner.on_hold)
self.assertFalse(self.partner.hold_bg)
# Place partner on credit hold
with Form(self.partner) as form:
form.record.action_credit_hold()
self.assertTrue(self.partner.hold_bg)
self.assertTrue(self.partner.on_hold)
# Lift credit hold
with Form(self.partner) as form:
form.record.action_lift_credit_hold()
self.assertFalse(self.partner.hold_bg)
self.assertFalse(self.partner.on_hold)
def test_postpone_hold_functionality(self):
"""Test postpone hold until functionality"""
# Place partner on hold
with Form(self.partner) as form:
form.record.action_credit_hold()
self.assertTrue(self.partner.on_hold)
# Set postpone date to tomorrow
tomorrow = date.today() + timedelta(days=1)
self.partner.postpone_hold_until = tomorrow
# Partner should not be on hold due to postponement
self.assertFalse(self.partner.on_hold)
# Set postpone date to yesterday
yesterday = date.today() - timedelta(days=1)
self.partner.postpone_hold_until = yesterday
# Partner should be on hold again
self.assertTrue(self.partner.on_hold)
def test_commercial_partner_hold_inheritance(self):
"""Test that child contacts inherit hold status from commercial partner"""
# Create child contact
child_partner = self.env["res.partner"].create(
{
"name": "Child Contact",
"parent_id": self.partner.id,
"type": "contact",
}
)
# Place parent on hold
with Form(self.partner) as form:
form.record.action_credit_hold()
# Child should also be on hold
self.assertTrue(child_partner.on_hold)
# Lift hold from parent
self.partner.action_lift_credit_hold()
# Child should no longer be on hold
self.assertFalse(child_partner.on_hold)
def test_sale_order_blocking(self):
"""Test that sale orders are blocked when customer is on credit hold"""
# Get or create a product for testing
product = self.env["product.product"].search([("type", "=", "consu")], limit=1)
if not product:
product = self.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"list_price": 100.0,
}
)
# Create a sale order
sale_order = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"product_id": product.id,
"product_uom_qty": 1,
"price_unit": 100.0,
},
)
],
}
)
# Should be able to confirm when not on hold
sale_order.action_confirm()
self.assertEqual(sale_order.state, "sale")
# Create another order and place customer on hold
sale_order2 = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"product_id": product.id,
"product_uom_qty": 1,
"price_unit": 100.0,
},
)
],
}
)
with Form(self.partner) as form:
form.record.action_credit_hold()
# Should raise error when trying to confirm
with self.assertRaises(UserError):
sale_order2.action_confirm()
def test_followup_integration(self):
"""Test integration with followup system"""
# Set partner to in_need_of_action status and assign followup line
self.partner.write(
{
"followup_status": "in_need_of_action",
"followup_line_id": self.followup_line.id,
}
)
# Execute followup - should place on hold
self.partner._execute_followup_partner()
self.assertTrue(self.partner.hold_bg)
# Test with followup line that doesn't have account_hold
self.partner.write(
{
"followup_line_id": self.followup_line_no_hold.id,
"hold_bg": False, # Reset hold status
}
)
# Execute followup - should not place on hold
self.partner._execute_followup_partner()
self.assertFalse(self.partner.hold_bg)
def test_followup_report_options(self):
"""Test that followup report includes credit hold information"""
# Set up partner with followup line
self.partner.write(
{
"followup_line_id": self.followup_line.id,
}
)
self.partner.action_credit_hold()
# Get followup report options
report = self.env["account.followup.report"]
options = report._get_followup_report_options(self.partner)
# Should include credit hold information
self.assertTrue(options.get("credit_hold"))
self.assertTrue(options.get("partner_on_hold"))
def test_cleanup_expired_hold_postponements(self):
"""Test automatic cleanup of expired hold postponements"""
# Set expired postponement date
expired_date = date.today() - timedelta(days=5)
self.partner.postpone_hold_until = expired_date
# Run cleanup
self.env["res.partner"]._cleanup_expired_hold_postponements()
# Postponement should be cleared
self.assertFalse(self.partner.postpone_hold_until)
def test_hold_bg_computation(self):
"""Test hold_bg field computation based on followup status"""
# Test with no_action_needed status
self.partner.write(
{
"followup_status": "no_action_needed",
"followup_line_id": False,
}
)
self.partner._compute_hold_bg()
self.assertFalse(self.partner.hold_bg)
# Test with followup status and line
self.partner.write(
{
"followup_status": "in_need_of_action",
"followup_line_id": self.followup_line.id,
"hold_bg": True, # Set initial state
}
)
self.partner._compute_hold_bg()
# Should preserve existing hold_bg value when there's a followup line
self.assertTrue(self.partner.hold_bg)
def test_stock_picking_credit_hold_display(self):
"""Test that stock pickings show credit hold status"""
# Get warehouse and its outgoing picking type
warehouse = self.env["stock.warehouse"].search([], limit=1)
picking_type = warehouse.out_type_id
# Create a stock picking
picking = self.env["stock.picking"].create(
{
"partner_id": self.partner.id,
"picking_type_id": picking_type.id,
"location_id": picking_type.default_location_src_id.id,
"location_dest_id": picking_type.default_location_dest_id.id,
}
)
# Initially should not show as on hold
self.assertFalse(picking.client_on_hold)
# Place partner on hold
with Form(self.partner) as form:
form.record.action_credit_hold()
# Picking should now show as on hold
self.assertTrue(picking.client_on_hold)
def test_get_first_followup_level(self):
"""Test _get_first_followup_level method"""
first_level = self.partner._get_first_followup_level()
self.assertEqual(first_level, self.followup_line)
# Create an earlier followup level with unique delay
existing_delays = (
self.env["account_followup.followup.line"]
.search([("company_id", "=", self.env.company.id)])
.mapped("delay")
)
delay = 5
while delay in existing_delays:
delay += 1
earlier_line = self.env["account_followup.followup.line"].create(
{
"name": "Early Reminder",
"delay": delay,
"account_hold": False,
"company_id": self.env.company.id,
}
)
first_level = self.partner._get_first_followup_level()
self.assertEqual(first_level, earlier_line)

View file

@ -12,22 +12,32 @@
<field name="account_hold" />
</xpath></field>
</record>
<record id="manual_reminder_view_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.manual_reminder.form.inherit</field>
<field name="model">account_followup.manual_reminder</field>
<record id="customer_statements_form_view_inherit" model="ir.ui.view">
<field name="name">customer.statements.form.view.inherit</field>
<field name="model">res.partner</field>
<field
name="inherit_id"
ref="account_followup.manual_reminder_view_form"
ref="account_followup.customer_statements_form_view"
/>
<field name="arch" type="xml">
<xpath expr="//footer" position="before">
<div class="alert alert-warning" role="alert" invisible="not partner_id.on_hold">
<strong>Credit Hold:</strong> This customer is currently on credit hold.
</div>
<xpath expr="//button[last()]" position="after">
<field invisible="1" name="hold_bg" />
<button
class="button btn-secondary"
invisible="hold_bg == True"
name="action_credit_hold"
string="Credit Hold"
type="object"
/>
<button
class="button btn-secondary"
invisible="hold_bg == False"
name="action_lift_credit_hold"
string="Lift Credit Hold"
type="object"
/>
</xpath></field>
</record>
<record id="action_credit_hold" model="ir.actions.server">
<field name="name">action_credit_hold</field>
<field name="model_id" ref="base.model_res_partner" />

View file

@ -1,23 +0,0 @@
{
"name": "Account Email to PDF",
"version": "18.0.1.0.0",
"category": "Accounting",
"summary": "Convert email messages to PDF attachments for vendor bills",
"description": """
Account Email to PDF
====================
This module converts email messages without attachments into PDF attachments
when processing incoming emails for vendor bills.
Instead of rejecting emails without attachments, the system will create a PDF
from the email content and attach it to the message, allowing the vendor bill
creation process to continue.
""",
"author": "Bemade",
"website": "https://bemade.org",
"depends": ["account", "mail"],
"data": [],
"installable": True,
"auto_install": False,
"license": "LGPL-3",
}

View file

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

View file

@ -1,261 +0,0 @@
import logging
import os
import subprocess
import tempfile
from contextlib import closing
from datetime import datetime
from html import escape
import re
from odoo import models, api
from odoo.tools.misc import find_in_path
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = "account.move"
@api.model
def _routing_check_route(self, message, message_dict, route, raise_exception=True):
"""Override to create a PDF attachment from the email content before checking the route.
The standard Odoo behavior bounces emails without attachments, but we want to
process them by generating a PDF from the email content.
"""
if route[0] == "account.move" and (
len(message_dict.get("attachments", [])) < 1
or message_dict["attachments"][0][0].lower().endswith(".eml")
):
try:
# Create a PDF from the email content
pdf_attachment = self._create_pdf_from_email(message_dict)
if pdf_attachment:
# Add the PDF to the message's attachments
if not message_dict.get("attachments"):
message_dict["attachments"] = []
# Convert the attachment to the format expected by mail_thread
# Format: (name, base64_data, info_dict)
attachment_data = (
pdf_attachment["name"],
pdf_attachment["datas"],
{"mimetype": "application/pdf"},
)
message_dict["attachments"].append(attachment_data)
except Exception as e:
_logger.exception(
"Error creating PDF from email in _routing_check_route: %s", e
)
return super()._routing_check_route(
message, message_dict, route, raise_exception=raise_exception
)
@classmethod
def _html_to_pdf(cls, html_content):
"""Convert HTML content to PDF using wkhtmltopdf.
Args:
html_content (str): HTML content to convert to PDF
Returns:
bytes: PDF content as bytes or False if conversion failed
"""
# Check if the content is plain text (not HTML)
# More comprehensive check for HTML content
is_html = bool(re.search(r"<html", html_content, re.IGNORECASE))
if not is_html:
# Escape the content if it's not already HTML
escaped_content = escape(html_content)
# Wrap the content in basic HTML structure with proper styling for plain text
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
pre {{ white-space: pre-wrap; font-family: monospace; background-color: #f9f9f9; padding: 10px; }}
</style>
</head>
<body>
<pre>{escaped_content}</pre>
</body>
</html>
"""
# Check if wkhtmltopdf is installed
wkhtmltopdf_bin = find_in_path("wkhtmltopdf")
if not wkhtmltopdf_bin:
_logger.error("Cannot find wkhtmltopdf executable in system path")
return False
# Create temporary files for the HTML input and PDF output
html_file_fd, html_file_path = tempfile.mkstemp(
suffix=".html", prefix="email_to_pdf."
)
pdf_file_fd, pdf_file_path = tempfile.mkstemp(
suffix=".pdf", prefix="email_to_pdf."
)
try:
# Write the HTML content to the temporary file
with closing(os.fdopen(html_file_fd, "wb")) as html_file:
encoded_content = html_content.encode("utf-8")
html_file.write(encoded_content)
# Close the PDF file descriptor as wkhtmltopdf will write to it
os.close(pdf_file_fd)
# Basic wkhtmltopdf command arguments
command = [wkhtmltopdf_bin]
command.extend(["--encoding", "utf-8"])
command.extend(["--page-size", "A4"])
command.extend(["--margin-top", "10mm"])
command.extend(["--margin-bottom", "10mm"])
command.extend(["--margin-left", "10mm"])
command.extend(["--margin-right", "10mm"])
command.append(html_file_path)
command.append(pdf_file_path)
process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
out, err = process.communicate()
if process.returncode != 0:
_logger.error(
"wkhtmltopdf failed with error code %s: %s", process.returncode, err
)
return False
# Read the generated PDF
try:
with open(pdf_file_path, "rb") as pdf_file:
pdf_content = pdf_file.read()
return pdf_content
except Exception as e:
_logger.exception("Error reading generated PDF file: %s", e)
return False
except Exception as e:
_logger.exception("Error during PDF generation: %s", e)
return False
finally:
# Clean up temporary files
try:
os.unlink(html_file_path)
os.unlink(pdf_file_path)
except (OSError, IOError):
_logger.error("Failed to remove temporary files")
def _create_pdf_from_email(self, message_dict):
"""Create a PDF attachment from an email message.
Args:
message_dict (dict): Email message dictionary
Returns:
dict: Dictionary with keys `name` and `datas` containing the name and
base64 encoded data of the attachment
"""
# Log message dict keys for debugging
_logger.info(f"Message dict keys: {list(message_dict.keys())}")
# Extract email details
email_from = message_dict.get("email_from", "Unknown Sender")
email_date = message_dict.get("date", datetime.now())
subject = message_dict.get("subject", "No Subject")
body = message_dict.get("body", "")
# Format the date if it's a datetime object
if isinstance(email_date, datetime):
email_date = email_date.strftime("%Y-%m-%d %H:%M:%S")
# Check if body is empty or None
if not body:
body = "<p>This email did not contain any body content.</p>"
# Check if the body is plain text based on content-type
# The content type can be found in the message headers or directly in the message_dict
content_type = ""
# Try to get content type from various places in the message dict
if "content-type" in message_dict:
content_type = message_dict["content-type"].lower()
# Try to get from headers
headers = message_dict.get("headers", {})
if not content_type and headers:
if isinstance(headers, dict):
content_type = headers.get("Content-Type", "").lower()
elif isinstance(headers, list):
for header in headers:
if (
isinstance(header, tuple)
and len(header) >= 2
and header[0].lower() == "content-type"
):
content_type = header[1].lower()
break
# Try to determine from the body content if we still don't have a content type
if not content_type:
if re.search(r"<html", body, re.IGNORECASE):
content_type = "text/html"
else:
content_type = "text/plain"
# Convert plain text to HTML if needed
if "text/plain" in content_type:
# Replace newlines with <br> tags and wrap in paragraph tags
# Escape HTML special characters to prevent injection
body = f"<div style='white-space: pre-wrap; font-family: monospace;'>{escape(body)}</div>"
# Create HTML content for the PDF
html_content = f"""
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 10px; margin-bottom: 20px; }}
.email-meta {{ color: #666; font-size: 0.9em; margin-bottom: 5px; }}
.email-subject {{ font-size: 1.2em; font-weight: bold; margin-bottom: 15px; }}
.email-body {{ line-height: 1.5; }}
</style>
</head>
<body>
<div class="email-header">
<div class="email-meta">From: {escape(email_from)}</div>
<div class="email-meta">Date: {escape(email_date)}</div>
<div class="email-subject">{escape(subject)}</div>
</div>
<div class="email-body">
{body}
</div>
</body>
</html>
"""
# Convert HTML to PDF
pdf_content = self._html_to_pdf(html_content)
if not pdf_content:
_logger.error("PDF generation failed")
return False
# Create a proper ir.attachment record
filename = f"Email_{subject.replace(' ', '_')[:30]}.pdf"
# Note: No need to base64 encode the PDF content here
# When this attachment is added to message_dict["attachments"], Odoo expects raw binary data
# Odoo will handle the base64 encoding when creating the actual ir.attachment record
attachment = {
"name": filename,
"datas": pdf_content,
}
return attachment

View file

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

View file

@ -1,319 +0,0 @@
from odoo.tests.common import TransactionCase
from odoo.tools.misc import find_in_path
from datetime import datetime
import base64
class TestHtmlToPdf(TransactionCase):
"""
Test the functionality of converting an email to a PDF when it's received for a supplier invoice.
This test simulates the full flow of an email being received through
the mail alias and verifies that an account move is created with a PDF
attachment generated from the email content.
It's important to also run the tests in account, which can be done using the test tag:
`/account:TestAccountIncomingSupplierInvoice.test_extend_with_attachments_document_formats`
This specific test ensures we are not creating more attachments than necessary.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Set up sender and alias for testing
cls.sender_email = "sender@example.com"
cls.alias_email = "test-invoices@example.com"
# Check if wkhtmltopdf is available
cls.wkhtmltopdf_available = bool(find_in_path("wkhtmltopdf"))
# Get the model class to access the classmethod
cls.account_move = cls.env["account.move"]
# Simple test HTML content
cls.test_html = """
<html>
<head>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>Test HTML Document</h1>
<p>This is a test paragraph with <b>bold text</b> and <i>italic text</i>.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
<li>List item 3</li>
</ul>
</body>
</html>
"""
# Set up a supplier for testing
cls.supplier = cls.env["res.partner"].create(
{
"name": "Test Supplier",
"email": cls.sender_email,
}
)
# Set up a journal for incoming invoices
cls.journal = cls.env["account.journal"].search(
[("type", "=", "purchase")], limit=1
)
# Set up the mail alias domain
cls.alias_domain = cls.env["mail.alias.domain"].create({"name": "example.com"})
# Set up a mail alias for the journal
cls.alias = cls.env["mail.alias"].create(
{
"alias_name": cls.alias_email,
"alias_model_id": cls.env["ir.model"]
.search([("model", "=", "account.move")], limit=1)
.id,
"alias_domain_id": cls.alias_domain.id,
"alias_defaults": f'{{"move_type": "in_invoice", "journal_id": {cls.journal.id}}}',
}
)
cls.journal.alias_id = cls.alias
def test_html_to_pdf_conversion(self):
"""Test the direct HTML to PDF conversion."""
if not self.wkhtmltopdf_available:
self.skipTest("wkhtmltopdf not available")
# Call the method to convert HTML to PDF
pdf_content = self.account_move._html_to_pdf(self.test_html)
# Verify the PDF was created
self.assertTrue(pdf_content, "PDF content should be generated")
# Verify it's a valid PDF
self.assertTrue(
pdf_content.startswith(b"%PDF-"), "Content should be a valid PDF"
)
self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")
def test_plain_text_to_pdf_conversion(self):
"""Test the conversion of plain text email to PDF."""
if not self.wkhtmltopdf_available:
self.skipTest("wkhtmltopdf not available")
# Simple plain text content
plain_text = "This is a plain text email.\nIt has no HTML formatting.\nJust plain text content.\n\nRegards,\nTest Sender"
# Call the method to convert plain text to PDF
pdf_content = self.account_move._html_to_pdf(plain_text)
# Verify the PDF was created
self.assertTrue(
pdf_content, "PDF content should be generated even for plain text"
)
# Verify it's a valid PDF
is_pdf = pdf_content and pdf_content.startswith(b"%PDF-")
self.assertTrue(is_pdf, "Content should be a valid PDF")
self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")
# Integration test
def test_account_email_to_pdf_full_flow(self):
"""Test that an email without attachments is correctly converted to PDF
and an account move is created.
This test simulates the full flow of an email being received through
the mail alias and verifies that an account move is created with a PDF
attachment generated from the email content.
"""
# Get the current timestamp
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
message_id = f"<test123_{timestamp}@example.com>"
subject = f"Invoice from Test Supplier {timestamp}"
date = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000")
# HTML body of the email
html_body = "<html><body><h1>Invoice Test</h1><p>This is a test invoice from Test Supplier.</p><p>Amount: $100.00</p><p>Date: 2025-03-27</p></body></html>"
# Construct the raw email with proper headers
# Make sure the From header is correctly formatted for Odoo to parse
raw_email = f"""Return-Path: <{self.sender_email}>
X-Original-To: {self.alias_email}
Delivered-To: {self.alias_email}
Received: from mail.example.com (mail.example.com [192.168.1.1])
From: {self.sender_email}
To: {self.alias_email}
Subject: {subject}
Date: {date}
Message-ID: {message_id}
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
{html_body}"""
# Process the email through the mail gateway
# This simulates what happens when fetchmail receives an email
invoice_count_before = self.env["account.move"].search_count(
[("move_type", "=", "in_invoice")]
)
# Process the email through the mail gateway
# We need to specify the model explicitly since we're in a test environment
# In production, the alias would determine this automatically
self.env["mail.thread"].with_context(fetchmail_server_id=1).message_process(
model=None,
message=raw_email,
save_original=True,
strip_attachments=False,
)
# Count account moves after the test
# Note: move_type is the correct field name in Odoo, even if the linter doesn't recognize it
move_count_after = self.env["account.move"].search_count(
[("move_type", "=", "in_invoice")]
)
self.assertEqual(
move_count_after,
invoice_count_before + 1,
"A new account move should be created",
)
# Find the newly created invoice
invoice = self.env["account.move"].search(
[("move_type", "=", "in_invoice")], order="id desc", limit=1
)
self.assertTrue(invoice, "An account move should be created")
messages = invoice.message_ids
self.assertEqual(len(messages), 1, "The account move should have one message")
attachment = messages.attachment_ids
self.assertTrue(attachment, "The message should have an attachment")
attachment = attachment.filtered(
lambda a: a.mimetype == "application/pdf" or ".pdf" in a.name
)
self.assertTrue(attachment, "The message should have a PDF attachment")
# Verify the invoice is linked to the correct supplier
self.assertEqual(
invoice.partner_id,
self.supplier,
"The invoice should be linked to the correct supplier",
)
# Verify the invoice type
self.assertEqual(
invoice.move_type, "in_invoice", "The invoice should be an incoming invoice"
)
def test_plain_text_email_to_pdf_full_flow(self):
"""Test that a plain text email without attachments is correctly converted to PDF
and an account move is created.
This test simulates the full flow of a plain text email being received through
the mail alias and verifies that an account move is created with a PDF
attachment generated from the email content.
"""
# Get the current timestamp
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
message_id = f"<test123_{timestamp}@example.com>"
subject = f"Plain Text Invoice from Test Supplier {timestamp}"
date = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000")
# Plain text body of the email
plain_text_body = """Invoice Test
This is a test invoice from Test Supplier.
Amount: $100.00
Date: 2025-03-27
Thank you for your business.
"""
# Construct the raw email with proper headers
# Make sure the From header is correctly formatted for Odoo to parse
raw_email = f"""Return-Path: <{self.sender_email}>
X-Original-To: {self.alias_email}
Delivered-To: {self.alias_email}
Received: from mail.example.com (mail.example.com [192.168.1.1])
From: {self.sender_email}
To: {self.alias_email}
Subject: {subject}
Date: {date}
Message-ID: {message_id}
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
{plain_text_body}"""
# Process the email through the mail gateway
# This simulates what happens when fetchmail receives an email
invoice_count_before = self.env["account.move"].search_count(
[("move_type", "=", "in_invoice")]
)
# Process the email through the mail gateway
self.env["mail.thread"].with_context(fetchmail_server_id=1).message_process(
model=None,
message=raw_email,
save_original=True,
strip_attachments=False,
)
# Count account moves after the test
move_count_after = self.env["account.move"].search_count(
[("move_type", "=", "in_invoice")]
)
self.assertEqual(
move_count_after,
invoice_count_before + 1,
"A new account move should be created from plain text email",
)
# Find the newly created invoice
invoice = self.env["account.move"].search(
[("move_type", "=", "in_invoice")], order="id desc", limit=1
)
self.assertTrue(
invoice, "An account move should be created from plain text email"
)
messages = invoice.message_ids
self.assertEqual(len(messages), 1, "The account move should have one message")
attachment = messages.attachment_ids
self.assertTrue(attachment, "The message should have an attachment")
attachment = attachment.filtered(
lambda a: a.mimetype == "application/pdf" or ".pdf" in a.name
)
self.assertTrue(attachment, "The message should have a PDF attachment")
# Verify PDF content if possible
if attachment:
pdf_data = base64.b64decode(attachment.datas) if attachment.datas else b""
self.assertTrue(
pdf_data.startswith(b"%PDF-"),
f"Attachment should be a valid PDF even when source is plain text, starts with {pdf_data[:20]}",
)
self.assertTrue(
len(pdf_data) > 100, "PDF from plain text should have reasonable size"
)

View file

@ -17,16 +17,16 @@
# DEALINGS IN THE SOFTWARE.
#
{
"name": "Aged Partner Balance (North American Style)",
"version": "18.0.1.0.0",
"summary": "Present aged partner balance as predictive rather than past due.",
"category": "Accounting",
"author": "Bemade Inc.",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": ["account_reports"],
"assets": {},
"installable": True,
"auto_install": False,
"post_init_hook": "post_init",
'name': 'Aged Partner Balance (North American Style)',
'version': '17.0.1.0.0',
'summary': 'Present aged partner balance as predictive rather than past due.',
'category': 'Accounting',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'LGPL-3',
'depends': ['account_reports'],
'assets': {},
'installable': True,
'auto_install': False,
'post_init_hook': 'post_init',
}

View file

@ -1,5 +1,4 @@
from odoo import models, fields
from odoo.tools import SQL
from itertools import chain
from dateutil.relativedelta import relativedelta
@ -58,14 +57,13 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
return fields.Date.to_string(date_obj - relativedelta(days=days))
date_to = fields.Date.from_string(options['date']['date_to'])
# North American style: show future due dates instead of past due
periods = [
(False, minus_days(date_to, 1)), # Overdue
(date_to, plus_days(date_to, 29)), # 0-29 days
(plus_days(date_to, 30), plus_days(date_to, 59)), # 30-59 days
(plus_days(date_to, 60), plus_days(date_to, 89)), # 60-89 days
(plus_days(date_to, 90), plus_days(date_to, 119)), # 90-119 days
(plus_days(date_to, 120), False), # 120+ days
(False, minus_days(date_to, 1)),
(date_to, plus_days(date_to, 29)),
(plus_days(date_to, 30), plus_days(date_to, 59)),
(plus_days(date_to, 60), plus_days(date_to, 89)),
(plus_days(date_to, 90), plus_days(date_to, 119)),
(plus_days(date_to, 120), False),
]
def build_result_dict(report, query_res_lines):
@ -79,6 +77,7 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
if current_groupby == 'id':
query_res = query_res_lines[0] # We're grouping by id, so there is only 1 element in query_res_lines anyway
currency = self.env['res.currency'].browse(query_res['currency_id'][0]) if len(query_res['currency_id']) == 1 else None
expected_date = len(query_res['expected_date']) == 1 and query_res['expected_date'][0] or len(query_res['due_date']) == 1 and query_res['due_date'][0]
rslt.update({
'invoice_date': query_res['invoice_date'][0] if len(query_res['invoice_date']) == 1 else None,
'due_date': query_res['due_date'][0] if len(query_res['due_date']) == 1 else None,
@ -86,6 +85,7 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
'currency_id': query_res['currency_id'][0] if len(query_res['currency_id']) == 1 else None,
'currency': currency.display_name if currency else None,
'account_name': query_res['account_name'][0] if len(query_res['account_name']) == 1 else None,
'expected_date': expected_date or None,
'total': None,
'has_sublines': query_res['aml_count'] > 0,
@ -100,78 +100,74 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
'currency_id': None,
'currency': None,
'account_name': None,
'expected_date': None,
'total': sum(rslt[f'period{i}'] for i in range(len(periods))),
'has_sublines': False,
})
return rslt
# Build period table using SQL class
# Build period table
period_table_format = ('(VALUES %s)' % ','.join("(%s, %s, %s)" for period in periods))
params = list(chain.from_iterable(
(period[0] or None, period[1] or None, i)
for i, period in enumerate(periods)
))
period_table = SQL(period_table_format, *params)
period_table = self.env.cr.mogrify(period_table_format, params).decode(self.env.cr.connection.encoding)
# Build query using new Odoo 18.0 methods
query = report._get_report_query(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)])
account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
# Build query
tables, where_clause, where_params = report._query_get(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)])
always_present_groupby = SQL("period_table.period_index")
currency_table = report._get_query_currency_table(options)
always_present_groupby = "period_table.period_index, currency_table.rate, currency_table.precision"
if current_groupby:
groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query)
select_from_groupby = SQL("%s AS grouping_key,", groupby_field_sql)
groupby_clause = SQL("%s, %s", groupby_field_sql, always_present_groupby)
select_from_groupby = f"account_move_line.{current_groupby} AS grouping_key,"
groupby_clause = f"account_move_line.{current_groupby}, {always_present_groupby}"
else:
select_from_groupby = SQL()
select_from_groupby = ''
groupby_clause = always_present_groupby
multiplicator = -1 if internal_type == 'liability_payable' else 1
select_period_query = SQL(',').join(
SQL("""
CASE WHEN period_table.period_index = %(period_index)s
THEN %(multiplicator)s * SUM(%(balance_select)s)
ELSE 0 END AS %(column_name)s
""",
period_index=i,
multiplicator=multiplicator,
column_name=SQL.identifier(f"period{i}"),
balance_select=report._currency_table_apply_rate(SQL(
"account_move_line.balance - COALESCE(part_debit.amount, 0) + COALESCE(part_credit.amount, 0)"
)),
)
select_period_query = ','.join(
f"""
CASE WHEN period_table.period_index = {i}
THEN %s * (
SUM(ROUND(account_move_line.balance * currency_table.rate, currency_table.precision))
- COALESCE(SUM(ROUND(part_debit.amount * currency_table.rate, currency_table.precision)), 0)
+ COALESCE(SUM(ROUND(part_credit.amount * currency_table.rate, currency_table.precision)), 0)
)
ELSE 0 END AS period{i}
"""
for i in range(len(periods))
)
tail_query = report._get_engine_query_tail(offset, limit)
query = SQL(
"""
WITH period_table(date_start, date_stop, period_index) AS (%(period_table)s)
tail_query, tail_params = report._get_engine_query_tail(offset, limit)
query = f"""
WITH period_table(date_start, date_stop, period_index) AS ({period_table})
SELECT
%(select_from_groupby)s
%(multiplicator)s * (
{select_from_groupby}
%s * (
SUM(account_move_line.amount_currency)
- COALESCE(SUM(part_debit.debit_amount_currency), 0)
+ COALESCE(SUM(part_credit.credit_amount_currency), 0)
) AS amount_currency,
ARRAY_AGG(DISTINCT account_move_line.partner_id) AS partner_id,
ARRAY_AGG(account_move_line.payment_id) AS payment_id,
ARRAY_AGG(DISTINCT account_move_line.invoice_date) AS invoice_date,
ARRAY_AGG(DISTINCT move.invoice_date) AS invoice_date,
ARRAY_AGG(DISTINCT COALESCE(account_move_line.date_maturity, account_move_line.date)) AS report_date,
ARRAY_AGG(DISTINCT %(account_code)s) AS account_name,
ARRAY_AGG(DISTINCT account_move_line.expected_pay_date) AS expected_date,
ARRAY_AGG(DISTINCT account.code) AS account_name,
ARRAY_AGG(DISTINCT COALESCE(account_move_line.date_maturity, account_move_line.date)) AS due_date,
ARRAY_AGG(DISTINCT account_move_line.currency_id) AS currency_id,
COUNT(account_move_line.id) AS aml_count,
ARRAY_AGG(%(account_code)s) AS account_code,
%(select_period_query)s
ARRAY_AGG(account.code) AS account_code,
{select_period_query}
FROM %(table_references)s
FROM {tables}
JOIN account_journal journal ON journal.id = account_move_line.journal_id
%(currency_table_join)s
JOIN account_account account ON account.id = account_move_line.account_id
JOIN account_move move ON move.id = account_move_line.move_id
JOIN {currency_table} ON currency_table.company_id = account_move_line.company_id
LEFT JOIN LATERAL (
SELECT
@ -179,7 +175,7 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
SUM(part.debit_amount_currency) AS debit_amount_currency,
part.debit_move_id
FROM account_partial_reconcile part
WHERE part.max_date <= %(date_to)s AND part.debit_move_id = account_move_line.id
WHERE part.max_date <= %s AND part.debit_move_id = account_move_line.id
GROUP BY part.debit_move_id
) part_debit ON TRUE
@ -189,7 +185,7 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
SUM(part.credit_amount_currency) AS credit_amount_currency,
part.credit_move_id
FROM account_partial_reconcile part
WHERE part.max_date <= %(date_to)s AND part.credit_move_id = account_move_line.id
WHERE part.max_date <= %s AND part.credit_move_id = account_move_line.id
GROUP BY part.credit_move_id
) part_credit ON TRUE
@ -204,35 +200,33 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
OR COALESCE(account_move_line.date_maturity, account_move_line.date) <= DATE(period_table.date_stop)
)
WHERE %(search_condition)s
WHERE {where_clause}
GROUP BY %(groupby_clause)s
GROUP BY {groupby_clause}
HAVING
ROUND(SUM(%(having_debit)s), %(currency_precision)s) != 0
OR ROUND(SUM(%(having_credit)s), %(currency_precision)s) != 0
(
SUM(ROUND(account_move_line.debit * currency_table.rate, currency_table.precision))
- COALESCE(SUM(ROUND(part_debit.amount * currency_table.rate, currency_table.precision)), 0)
) != 0
OR
(
SUM(ROUND(account_move_line.credit * currency_table.rate, currency_table.precision))
- COALESCE(SUM(ROUND(part_credit.amount * currency_table.rate, currency_table.precision)), 0)
) != 0
{tail_query}
"""
ORDER BY %(groupby_clause)s
%(tail_query)s
""",
account_code=account_code,
period_table=period_table,
select_from_groupby=select_from_groupby,
select_period_query=select_period_query,
multiplicator=multiplicator,
table_references=query.from_clause,
currency_table_join=report._currency_table_aml_join(options),
date_to=date_to,
search_condition=query.where_clause,
groupby_clause=groupby_clause,
having_debit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance > 0 THEN account_move_line.balance else 0 END - COALESCE(part_debit.amount, 0)")),
having_credit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance < 0 THEN -account_move_line.balance else 0 END - COALESCE(part_credit.amount, 0)")),
currency_precision=self.env.company.currency_id.decimal_places,
tail_query=tail_query,
)
self._cr.execute(query)
multiplicator = -1 if internal_type == 'liability_payable' else 1
params = [
multiplicator,
*([multiplicator] * len(periods)),
date_to,
date_to,
*where_params,
*tail_params,
]
self._cr.execute(query, params)
query_res_lines = self._cr.dictfetchall()
if not current_groupby:

View file

@ -1,39 +0,0 @@
{
'name': 'AI Integration Base',
'version': '1.0',
'category': 'Technical',
'summary': 'Base module for AI integration',
'description': """
AI Integration Base
===================
This module provides the base framework for integrating various AI providers
into Odoo. It includes:
* Abstract interfaces for AI providers
* Base configuration for AI models
* Common utilities for AI integration
""",
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': [
'base',
'web',
'mail',
],
'data': [
'security/ai_security.xml',
'security/ir_rule.xml',
'security/ir.model.access.csv',
'views/res_company_views.xml',
'views/res_config_settings_views.xml',
'views/ai_provider_views.xml',
'views/ai_provider_instance_views.xml',
'views/ai_model_views.xml',
'views/ai_model_stats_views.xml',
'views/menu.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -1,9 +0,0 @@
from .mixins.ai_base_mixin import AIBaseMixin
from . import ai_generation_params
from . import ai_model
from . import ai_provider_interface
from . import ai_provider
from . import ai_provider_instance
from . import res_config_settings
from . import res_company
from . import ai_model_stats

View file

@ -1,42 +0,0 @@
from odoo import models, fields, api, _
class AIGenerationParams(models.AbstractModel):
_name = 'ai.generation.params'
_description = 'AI Generation Parameters'
# Base Generation Parameters
temperature = fields.Float(
string='Temperature',
help='Controls randomness in generation. Higher values make output more random, lower values more deterministic.',
default=0.7
)
repeat_penalty = fields.Float(
string='Repeat Penalty',
help='Penalty for repeating tokens. Higher values make repetition less likely.',
default=1.1
)
max_tokens = fields.Integer(
string='Max Tokens',
help='Maximum number of tokens to generate.',
default=2048
)
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where generation should stop.',
default=''
)
frequency_penalty = fields.Float(
string='Frequency Penalty',
help='Penalty for using frequent tokens. Higher values encourage using less frequent tokens.',
default=0.0
)
presence_penalty = fields.Float(
string='Presence Penalty',
help='Penalty for using tokens already in the text. Higher values encourage using new tokens.',
default=0.0
)

View file

@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class AIModel(models.Model):
_name = 'ai.model'
_description = 'AI Model'
_order = 'sequence, name'
_check_company = False # Disable automatic company checks
active = fields.Boolean(
string='Active',
default=True,
help='Whether this model is active and available for use')
name = fields.Char(
string='Name',
required=True,
help='Name of the AI model'
)
identifier = fields.Char(
string='Identifier',
required=True,
help='Technical identifier of the model (e.g., gpt-3.5-turbo, mistral-7b)'
)
provider_instance_id = fields.Many2one(
'ai.provider.instance',
string='Provider Instance',
required=True,
ondelete='cascade',
help='Provider instance this model belongs to'
)
provider_type = fields.Selection(
related='provider_instance_id.provider_type',
string='Provider Type',
store=True,
readonly=True,
help='Type of AI provider'
)
description = fields.Text(
string='Description',
help='Description of the model and its capabilities'
)
sequence = fields.Integer(
string='Sequence',
default=10,
help='Sequence for ordering models in lists and dropdowns'
)
is_active = fields.Boolean(
string='Model Active',
default=True,
help='Whether this model is currently active and available for use'
)
context_window = fields.Integer(
string='Context Window',
default=2048,
help='Maximum number of tokens in the context window'
)
_sql_constraints = [
('unique_identifier_provider',
'unique(identifier, provider_instance_id)',
'The model identifier must be unique per provider instance!')
]
def name_get(self):
"""Custom name_get to include provider instance in display name."""
result = []
for model in self:
name = f"{model.name} ({model.provider_instance_id.name})"
result.append((model.id, name))
return result

View file

@ -1,94 +0,0 @@
from odoo import models, fields, api
from datetime import datetime, timedelta
class AIModelStats(models.Model):
_name = 'ai.model.stats'
_description = 'AI Model Usage Statistics'
_order = 'date desc'
@api.model
def _has_provider_modules(self):
"""Check if any AI provider modules are installed."""
modules = ['ollama_ai_integration', 'chatgpt_ai_integration']
return any(self.env['ir.module.module'].search([('name', 'in', modules), ('state', '=', 'installed')]))
@api.model
def default_get(self, fields_list):
"""Override default_get to prevent creation if no provider modules are installed."""
if not self._has_provider_modules():
raise UserError(_('No AI provider modules are installed. Please install at least one provider module (e.g., Ollama or ChatGPT) before creating model statistics.'))
return super().default_get(fields_list)
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
provider_instance_id = fields.Many2one(
related='model_id.provider_instance_id',
string='Provider Instance',
store=True)
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
request_count = fields.Integer(string='Number of Requests', default=0)
token_count = fields.Integer(string='Total Tokens', default=0)
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
error_count = fields.Integer(string='Number of Errors', default=0)
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
_sql_constraints = [
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
]
def _update_stats(self, model, tokens, response_time, error=False, version=None):
"""Update statistics for a model."""
today = fields.Date.context_today(self)
stats = self.search([
('model_id', '=', model.id),
('date', '=', today)
])
if not stats:
stats = self.create({
'model_id': model.id,
'date': today,
'version': version
})
# Update statistics
new_count = stats.request_count + 1
new_tokens = stats.token_count + tokens
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
new_errors = stats.error_count + (1 if error else 0)
stats.write({
'request_count': new_count,
'token_count': new_tokens,
'avg_response_time': new_time,
'error_count': new_errors,
'version': version or stats.version # Update version if provided
})
@api.model
def get_model_stats(self, model_id, days=30):
"""Get statistics for a model over the specified number of days."""
start_date = fields.Date.today() - timedelta(days=days)
stats = self.search([
('model_id', '=', model_id),
('date', '>=', start_date)
])
return {
'daily_stats': [{
'date': stat.date,
'requests': stat.request_count,
'tokens': stat.token_count,
'response_time': stat.avg_response_time,
'errors': stat.error_count,
'version': stat.version
} for stat in stats],
'summary': {
'total_requests': sum(stat.request_count for stat in stats),
'total_tokens': sum(stat.token_count for stat in stats),
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
(sum(stat.request_count for stat in stats) if stats else 1),
'total_errors': sum(stat.error_count for stat in stats),
'versions_used': list(set(stat.version for stat in stats if stat.version))
}
}

View file

@ -1,73 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class AIProvider(models.Model):
_name = 'ai.provider'
_description = 'AI Provider'
_inherit = ['ai.provider.interface']
name = fields.Char(string='Name', required=True)
code = fields.Char(string='Code', required=True)
description = fields.Text(string='Description')
default_host = fields.Char(string='Default Host')
active = fields.Boolean(string='Active', default=True)
@api.model
def send_message(self, message, **kwargs):
"""Send a message to the AI provider and get a response.
Args:
message (dict): The message to send
**kwargs: Additional provider-specific parameters
Returns:
str: The response from the AI provider
Raises:
NotImplementedError: Must be implemented by specific providers
"""
raise NotImplementedError(_("Method send_message must be implemented by specific AI providers"))
@api.model
def get_models(self):
"""Get the list of available models from the provider.
Returns:
list: List of model information dictionaries
Raises:
NotImplementedError: Must be implemented by specific providers
"""
raise NotImplementedError(_("Method get_models must be implemented by specific AI providers"))
@api.model
def test_connection(self):
"""Test the connection to the AI provider.
Returns:
bool: True if connection is successful
Raises:
NotImplementedError: Must be implemented by specific providers
"""
raise NotImplementedError(_("Method test_connection must be implemented by specific AI providers"))
def _handle_error(self, error, context=''):
"""Common error handling for AI provider operations.
Args:
error (Exception): The error that occurred
context (str): Additional context about where the error occurred
Returns:
tuple: (success, message)
"""
error_msg = str(error)
log_msg = f"AI Provider Error{' in ' + context if context else ''}: {error_msg}"
_logger.error(log_msg)
return False, error_msg

View file

@ -1,106 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.addons.mail.models.mail_thread import MailThread
from odoo.exceptions import UserError
class BaseAIProviderInstance(models.Model):
_name = 'ai.provider.instance'
_description = 'AI Provider Instance'
_order = 'name'
_check_company = False # Disable automatic company checks
_inherit = ['mail.thread', 'ai.base.mixin']
@api.model
def default_get(self, fields_list):
"""Override default_get to prevent creation if no provider modules are installed."""
defaults = super().default_get(fields_list)
if defaults.get('provider_type', 'none') == 'none':
defaults['provider_type'] = 'ollama'
return defaults
active = fields.Boolean(
string='Active',
default=True,
help='Whether this provider instance is active and available for use')
name = fields.Char(
string='Name',
required=True,
help='Name of this provider instance (e.g., "OpenWebUI Production", "Ollama Local")'
)
provider_type = fields.Selection(
[('none', 'None')], # Base selection, will be extended by provider modules
string='Provider Type',
required=True,
default='none',
help='The type of AI provider for this instance',
ondelete={'none': lambda r: r.write({'provider_type': 'none'})}
)
host = fields.Char(
string='Host',
required=True,
help='Host address (e.g., "http://localhost:8080" or "https://api.example.com")'
)
api_key = fields.Char(
string='API Key',
help='API key if required by the provider',
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.
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',
'provider_instance_id',
copy=True,
string='Available Models'
)
timeout = fields.Integer(
string='Timeout',
default=60,
help='Maximum wait time for API calls (in seconds)'
)
max_retries = fields.Integer(
string='Max Retries',
default=3,
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)',
'Provider instance name must be unique!')
]
def test_connection(self):
"""Test the connection to this provider instance."""
self.ensure_one()
if self.provider_type == 'none':
raise UserError(_('Please select a provider type'))
return {'type': 'ir.actions.act_window_close'}
def sync_models(self):
"""Synchronize models from this provider instance."""
self.ensure_one()
if self.provider_type == 'none':
raise UserError(_('Please select a provider type'))

View file

@ -1,55 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from odoo import models, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class AIProviderInterface(models.AbstractModel):
_name = 'ai.provider.interface'
_description = 'AI Provider Interface'
@api.model
def send_message(self, message, **kwargs):
"""Send a message to the AI provider and get a response.
Args:
message (dict): The message to send
**kwargs: Additional provider-specific parameters
Returns:
dict: The response from the AI provider
"""
raise NotImplementedError()
def _get_provider_type(self):
"""Get the provider type code.
Returns:
str: The provider type code
"""
raise NotImplementedError()
def _get_model_info(self, instance, model_name):
"""Get detailed information about a specific model.
Args:
instance (ai.provider.instance): The provider instance
model_name (str): Name of the model
Returns:
dict: Model information
"""
raise NotImplementedError()
def _list_models(self, instance):
"""List available models for this provider instance.
Args:
instance (ai.provider.instance): The provider instance
Returns:
list: List of available models
"""
raise NotImplementedError()

View file

@ -1,90 +0,0 @@
# Documentation des Modèles AI Integration
## Vue d'ensemble
Le module AI Integration fournit une infrastructure flexible pour intégrer différents fournisseurs d'IA dans Odoo. Il est conçu pour être extensible et permettre l'ajout facile de nouveaux fournisseurs.
## Modèles Principaux
### 1. AI Provider (`ai.provider`)
- **Description**: Configuration de base des fournisseurs d'IA
- **Champs principaux**:
- `name`: Nom du fournisseur
- `code`: Code technique unique
- `description`: Description détaillée
- `default_host`: Hôte par défaut
- `active`: État actif/inactif
### 2. AI Provider Instance (`ai.provider.instance`)
- **Description**: Instance spécifique d'un fournisseur d'IA
- **Héritage**: `mail.thread`, `ai.base.mixin`
- **Champs principaux**:
- `name`: Nom de l'instance (ex: "OpenWebUI Production", "Ollama Local")
- `provider_id`: Fournisseur associé
- `provider_type`: Type de fournisseur (extensible par modules)
- `active`: État actif/inactif
- **Validation**:
- Vérifie la présence d'au moins un module fournisseur installé
### 3. AI Model (`ai.model`)
- **Description**: Modèles d'IA disponibles
- **Champs principaux**:
- `name`: Nom du modèle
- `identifier`: Identifiant technique (ex: gpt-3.5-turbo, mistral-7b)
- `provider_instance_id`: Instance du fournisseur (cascade)
- `provider_type`: Type de fournisseur (relié à l'instance)
- `active`: État actif/inactif
- `sequence`: Ordre d'affichage
- **Validation**:
- Vérifie la présence d'au moins un module fournisseur installé
### 4. AI Generation Parameters (`ai.generation.params`)
- **Description**: Paramètres de génération pour les modèles d'IA
- **Type**: Modèle abstrait
- **Champs principaux**:
- `temperature`: Contrôle de l'aléatoire (défaut: 0.7)
- `repeat_penalty`: Pénalité de répétition (défaut: 1.1)
- `max_tokens`: Nombre maximum de tokens (défaut: 2048)
- `stop_sequences`: Séquences d'arrêt
- `frequency_penalty`: Pénalité de fréquence (défaut: 0.0)
- `presence_penalty`: Pénalité de présence (défaut: 0.0)
## Configuration et Interfaces
### 1. Res Config Settings
- **Description**: Paramètres de configuration globaux
- **Champs principaux**:
- `default_provider_instance_id`: Instance de fournisseur par défaut
- `default_model_id`: Modèle par défaut
- `ai_batch_size`: Taille du lot pour le traitement
### 2. Res Company
- **Description**: Extensions des paramètres de société
- **Méthodes principales**:
- `_get_default_provider_instance`: Obtenir l'instance par défaut
### 3. AI Provider Interface (`ai.provider.interface`)
- **Description**: Interface abstraite pour les fournisseurs d'IA
- **Méthodes requises**:
- `send_message`: Envoyer un message
- `get_models`: Obtenir la liste des modèles
- `test_connection`: Tester la connexion
## Notes d'Implémentation
1. **Architecture Modulaire**:
- Modules fournisseurs disponibles: `ollama_ai_integration`, `chatgpt_ai_integration`
- Vérification de la présence d'au moins un module fournisseur avant création d'instances
2. **Héritage et Extensions**:
- Les instances de fournisseur héritent de `mail.thread` et `ai.base.mixin`
- Les paramètres de génération sont définis dans le modèle abstrait `ai.generation.params`
3. **Configuration Hiérarchique**:
- Configuration globale > Paramètres société > Instance
- Paramètres de génération personnalisables à plusieurs niveaux
4. **Sécurité et Validation**:
- Vérifications de sécurité intégrées
- Validation des modules requis
- Gestion des paramètres de génération avec valeurs par défaut

View file

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

View file

@ -1,167 +0,0 @@
# -*- coding: utf-8 -*-
from typing import List, Dict, Any, Optional
from odoo import models, api, fields, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class AIBaseMixin(models.AbstractModel):
"""Base mixin for AI integration providing both provider interaction and generation parameters.
This mixin combines the functionality of message handling and generation parameters
into a single, cohesive interface for AI integration.
"""
_name = 'ai.base.mixin'
_description = 'AI Integration Base Mixin'
# Basic Generation Parameters
temperature = fields.Float(
string='Temperature',
help='Sampling temperature. Range: [0.0 - 2.0]. Higher values make output more random, '
'lower values more deterministic.',
default=0.7,
digits=(3, 2))
top_p = fields.Float(
string='Top P',
help='Nucleus sampling: limits cumulative probability of tokens to sample from. '
'Range: [0.0 - 1.0].',
default=0.9,
digits=(3, 2))
max_tokens = fields.Integer(
string='Max Tokens',
help='Maximum number of tokens to generate. Range: [1 - 32768].',
default=2048)
stop_sequences = fields.Char(
string='Stop Sequences',
help='Comma-separated list of sequences where the model should stop generating')
# System Settings
timeout = fields.Integer(
string='Timeout',
help='Request timeout in seconds. Range: [1 - 300].',
default=30)
retry_count = fields.Integer(
string='Retry Count',
help='Number of times to retry failed requests. Range: [0 - 5].',
default=3)
stream_response = fields.Boolean(
string='Stream Response',
help='Enable response streaming for real-time output.',
default=False)
def _get_base_generation_params(self):
"""Get common generation parameters as a dictionary.
Returns:
dict: Dictionary containing all generation parameters
"""
self.ensure_one()
return {
'temperature': self.temperature,
'top_p': self.top_p,
'max_tokens': self.max_tokens,
'stop_sequences': self.stop_sequences.split(',') if self.stop_sequences else None,
'timeout': self.timeout,
'retry_count': self.retry_count,
'stream_response': self.stream_response,
}
def _get_ai_provider_instance(self, provider_instance_id=None):
"""Get the AI provider instance to use.
Args:
provider_instance_id: Optional specific provider instance to use
Returns:
ai.provider.instance: The provider instance to use
Raises:
UserError: If no provider instance is configured or available
"""
if provider_instance_id:
instance = self.env['ai.provider.instance'].browse(provider_instance_id)
if not instance.exists():
raise UserError(_("Invalid provider instance"))
else:
provider_id = self.env['ir.config_parameter'].sudo().get_param(
'ai_integration.default_provider_instance_id')
if not provider_id:
raise UserError(_("No default AI provider instance configured"))
instance = self.env['ai.provider.instance'].browse(int(provider_id))
if not instance.exists():
raise UserError(_("Default provider instance not found"))
if not instance.is_active:
raise UserError(_("The selected AI provider instance is not active"))
return instance
def _get_ai_model(self, model_id=None, provider_instance=None):
"""Get the AI model to use.
Args:
model_id: Optional specific model to use
provider_instance: Optional provider instance (to avoid duplicate lookup)
Returns:
ai.model: The model to use
Raises:
UserError: If no model is configured or available
"""
if not provider_instance:
provider_instance = self._get_ai_provider_instance()
if model_id:
model = self.env['ai.model'].browse(model_id)
if not model.exists():
raise UserError(_("Invalid model"))
if model.provider_instance_id != provider_instance:
raise UserError(_("Model does not belong to the selected provider instance"))
else:
model_id = self.env['ir.config_parameter'].sudo().get_param(
'ai_integration.default_model_id')
if not model_id:
raise UserError(_("No default AI model configured"))
model = self.env['ai.model'].browse(int(model_id))
if not model.exists():
raise UserError(_("Default model not found"))
if not model.is_active:
raise UserError(_("The selected AI model is not active"))
return model
def send_ai_message(self, message: Dict[str, Any], provider_instance_id: Optional[int] = None,
model_id: Optional[int] = None, **kwargs):
"""Send a message to an AI provider instance.
Args:
message: The message to send
provider_instance_id: Optional specific provider instance to use
model_id: Optional specific model to use
**kwargs: Additional provider-specific parameters
Returns:
str: The response from the AI provider
Raises:
UserError: If there's an error with the AI provider
"""
provider_instance = self._get_ai_provider_instance(provider_instance_id)
model = self._get_ai_model(model_id, provider_instance)
# Merge generation parameters with provider-specific parameters
params = {**self._get_base_generation_params(), **kwargs}
try:
return provider_instance.send_message(message, model=model, **params)
except Exception as e:
_logger.error("Error sending message to AI provider: %s", str(e))
raise UserError(_("Failed to send message to AI provider: %s") % str(e))

View file

@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class ResCompany(models.Model):
_inherit = 'res.company'
def _get_default_provider_instance(self):
"""Get the default AI provider instance from config parameters"""
provider_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_provider_instance_id')
return self.env['ai.provider.instance'].browse(int(provider_id)) if provider_id else False
def _get_default_model(self):
"""Get the default AI model from config parameters"""
model_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_model_id')
return self.env['ai.model'].browse(int(model_id)) if model_id else False
def _get_ai_batch_size(self):
"""Get the AI batch size from config parameters"""
return int(self.env['ir.config_parameter'].sudo().get_param('ai_integration.ai_batch_size', '100'))

View file

@ -1,22 +0,0 @@
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
default_provider_instance_id = fields.Many2one(
'ai.provider.instance',
string='Default AI Provider Instance',
config_parameter='ai_integration.default_provider_instance_id',
default_model='ai.provider.instance')
default_model_id = fields.Many2one(
'ai.model',
string='Default AI Model',
config_parameter='ai_integration.default_model_id',
default_model='ai.model')
ai_batch_size = fields.Integer(
string='AI Batch Processing Size',
config_parameter='ai_integration.ai_batch_size',
default=100,
default_model='ai.provider.instance')

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- AI User Group -->
<record id="group_ai_user" model="res.groups">
<field name="name">AI User</field>
<field name="category_id" ref="base.module_category_services"/>
<field name="comment">Users can use AI services but cannot configure them.</field>
</record>
<!-- AI Manager Group -->
<record id="group_ai_manager" model="res.groups">
<field name="name">AI Manager</field>
<field name="category_id" ref="base.module_category_services"/>
<field name="implied_ids" eval="[(4, ref('group_ai_user'))]"/>
<field name="comment">Full access to AI configuration and usage.</field>
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
</record>
</data>
</odoo>

View file

@ -1,11 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_model_user,ai.model.user,model_ai_model,base.group_user,1,0,0,0
access_ai_model_system,ai.model.system,model_ai_model,base.group_system,1,1,1,1
access_ai_provider_instance_user,ai.provider.instance.user,model_ai_provider_instance,base.group_user,1,0,0,0
access_ai_provider_instance_system,ai.provider.instance.system,model_ai_provider_instance,base.group_system,1,1,1,1
access_ai_model_stats_user,ai.model.stats.user,model_ai_model_stats,base.group_user,1,0,0,0
access_ai_model_stats_system,ai.model.stats.system,model_ai_model_stats,base.group_system,1,1,1,1
access_ai_generation_params_user,ai.generation.params.user,model_ai_generation_params,base.group_user,1,0,0,0
access_ai_generation_params_system,ai.generation.params.system,model_ai_generation_params,base.group_system,1,1,1,1
access_ai_provider_user,ai.provider.user,model_ai_provider,base.group_user,1,0,0,0
access_ai_provider_system,ai.provider.system,model_ai_provider,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_model_user ai.model.user model_ai_model base.group_user 1 0 0 0
3 access_ai_model_system ai.model.system model_ai_model base.group_system 1 1 1 1
4 access_ai_provider_instance_user ai.provider.instance.user model_ai_provider_instance base.group_user 1 0 0 0
5 access_ai_provider_instance_system ai.provider.instance.system model_ai_provider_instance base.group_system 1 1 1 1
6 access_ai_model_stats_user ai.model.stats.user model_ai_model_stats base.group_user 1 0 0 0
7 access_ai_model_stats_system ai.model.stats.system model_ai_model_stats base.group_system 1 1 1 1
8 access_ai_generation_params_user ai.generation.params.user model_ai_generation_params base.group_user 1 0 0 0
9 access_ai_generation_params_system ai.generation.params.system model_ai_generation_params base.group_system 1 1 1 1
10 access_ai_provider_user ai.provider.user model_ai_provider base.group_user 1 0 0 0
11 access_ai_provider_system ai.provider.system model_ai_provider base.group_system 1 1 1 1

View file

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Global Provider Instance Rule -->
<record id="ai_provider_instance_global_rule" model="ir.rule">
<field name="name">AI Provider Instance: Global Access</field>
<field name="model_id" ref="model_ai_provider_instance"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Global Model Rule -->
<record id="ai_model_global_rule" model="ir.rule">
<field name="name">AI Model: Global Access</field>
<field name="model_id" ref="model_ai_model"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</data>
</odoo>

View file

@ -1,118 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_ai_model_stats_list" model="ir.ui.view">
<field name="name">ai.model.stats.list</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<list string="Model Statistics" create="false">
<field name="date"/>
<field name="model_id"/>
<field name="provider_instance_id"/>
<field name="version"/>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_ai_model_stats_form" model="ir.ui.view">
<field name="name">ai.model.stats.form</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<form string="Model Statistics">
<sheet>
<group>
<group>
<field name="date"/>
<field name="model_id"/>
<field name="provider_instance_id"/>
<field name="version"/>
</group>
<group>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_ai_model_stats_search" model="ir.ui.view">
<field name="name">ai.model.stats.search</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<search string="Search Model Statistics">
<field name="model_id"/>
<field name="provider_instance_id"/>
<field name="version"/>
<field name="date"/>
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<!-- Graph View -->
<record id="view_ai_model_stats_graph" model="ir.ui.view">
<field name="name">ai.model.stats.graph</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<graph string="Model Statistics" type="line" sample="1">
<field name="date" type="row"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="error_count" type="measure"/>
</graph>
</field>
</record>
<!-- Pivot View -->
<record id="view_ai_model_stats_pivot" model="ir.ui.view">
<field name="name">ai.model.stats.pivot</field>
<field name="model">ai.model.stats</field>
<field name="arch" type="xml">
<pivot string="Model Statistics" sample="1">
<field name="model_id" type="row"/>
<field name="provider_instance_id" type="row"/>
<field name="date" type="col"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="avg_response_time" type="measure"/>
<field name="error_count" type="measure"/>
</pivot>
</field>
</record>
<!-- Action -->
<record id="action_ai_model_stats" model="ir.actions.act_window">
<field name="name">Model Statistics</field>
<field name="res_model">ai.model.stats</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="context">{'search_default_last_month': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No statistics recorded yet
</p>
<p>
Statistics will be automatically recorded as you use your AI models.
</p>
</field>
</record>
</odoo>

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="ai_model_view_list" model="ir.ui.view">
<field name="name">ai.model.list</field>
<field name="model">ai.model</field>
<field name="type">list</field>
<field name="arch" type="xml">
<list string="AI Models" create="false">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="provider_instance_id"/>
<field name="provider_type"/>
<field name="identifier"/>
<field name="context_window"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="ai_model_view_form" model="ir.ui.view">
<field name="name">ai.model.form</field>
<field name="model">ai.model</field>
<field name="arch" type="xml">
<form string="AI Model">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
</button>
</div>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="e.g. GPT-3.5 Turbo"/></h1>
</div>
<group>
<group>
<field name="provider_instance_id" options="{'no_create': True}"/>
<field name="provider_type"/>
<field name="identifier"/>
<field name="context_window"/>
</group>
<group>
<field name="sequence"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<field name="description" placeholder="Enter a description of the model and its capabilities..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="ai_model_view_search" model="ir.ui.view">
<field name="name">ai.model.search</field>
<field name="model">ai.model</field>
<field name="arch" type="xml">
<search string="Search AI Models">
<field name="name"/>
<field name="identifier"/>
<field name="provider_instance_id"/>
<field name="provider_type"/>
<filter string="Archived" name="inactive" domain="[('is_active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="ai_model_action" model="ir.actions.act_window">
<field name="name">AI Models</field>
<field name="res_model">ai.model</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="ai_model_view_search"/>
<field name="context">{'search_default_group_by_provider': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No AI models found
</p>
<p>
AI models will be automatically synchronized when you configure and sync a provider instance.
</p>
</field>
</record>
</odoo>

View file

@ -1,107 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="ai_provider_instance_view_list" model="ir.ui.view">
<field name="name">ai.provider.instance.list</field>
<field name="model">ai.provider.instance</field>
<field name="arch" type="xml">
<list name="ai_instance" string="AI Provider Instances" create="true">
<field name="name"/>
<field name="provider_type"/>
<field name="host"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="ai_provider_instance_view_form" model="ir.ui.view">
<field name="name">ai.provider.instance.form</field>
<field name="model">ai.provider.instance</field>
<field name="arch" type="xml">
<form string="AI Provider Instance">
<header>
<button name="test_connection"
string="Test Connection"
type="object"
class="oe_highlight"/>
<button name="sync_models"
string="Sync Models"
type="object"
class="btn-primary"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
</button>
</div>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="e.g. OpenWebUI Production"/></h1>
</div>
<group>
<group>
<field name="provider_type"/>
<field name="host"/>
<field name="api_key"/>
</group>
<group>
<field name="timeout"/>
<field name="max_retries"/>
</group>
</group>
<notebook>
<page string="Models" name="models">
<field name="model_ids" nolabel="1">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="identifier"/>
<field name="context_window"/>
<field name="active"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="ai_provider_instance_view_search" model="ir.ui.view">
<field name="name">ai.provider.instance.search</field>
<field name="model">ai.provider.instance</field>
<field name="arch" type="xml">
<search string="Search AI Provider Instances">
<field name="name"/>
<field name="provider_type"/>
<field name="host"/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="ai_provider_instance_action" model="ir.actions.act_window">
<field name="name">AI Provider Instances</field>
<field name="res_model">ai.provider.instance</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="ai_provider_instance_view_search"/>
<field name="context">{'company_id': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first AI provider instance
</p>
<p>
Configure AI provider instances to connect to different AI services.
</p>
</field>
</record>
</odoo>

View file

@ -1,88 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="ai_provider_view_tree" model="ir.ui.view">
<field name="name">ai.provider.tree</field>
<field name="model">ai.provider</field>
<field name="type">list</field>
<field name="arch" type="xml">
<list string="AI Providers" create="false">
<field name="name"/>
<field name="code"/>
<field name="default_host"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="ai_provider_view_form" model="ir.ui.view">
<field name="name">ai.provider.form</field>
<field name="model">ai.provider</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form string="AI Provider">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="test_connection" type="object"
string="Test Connection" class="oe_stat_button"
icon="fa-plug"/>
<button name="get_models" type="object"
string="Get Models" class="oe_stat_button"
icon="fa-list"/>
</div>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="e.g. Ollama"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="default_host"/>
</group>
<group>
<field name="active"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<field name="description" nolabel="1" placeholder="Enter a description..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="ai_provider_view_search" model="ir.ui.view">
<field name="name">ai.provider.search</field>
<field name="model">ai.provider</field>
<field name="type">search</field>
<field name="arch" type="xml">
<search string="Search AI Providers">
<field name="name"/>
<field name="code"/>
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<!-- Action -->
<record id="ai_provider_action" model="ir.actions.act_window">
<field name="name">AI Providers</field>
<field name="res_model">ai.provider</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No AI providers found
</p>
<p>
AI providers are automatically created when you install AI integration modules.
</p>
</field>
</record>
</odoo>

View file

@ -1,51 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top level menu -->
<menuitem id="menu_ai_root"
name="AI Integration"
web_icon="ai_integration,static/description/icon.png"
sequence="50"/>
<!-- Configuration menu -->
<menuitem id="menu_ai_config"
name="Configuration"
parent="menu_ai_root"
sequence="10"/>
<!-- Settings menu -->
<menuitem id="menu_ai_settings"
name="Settings"
parent="menu_ai_config"
action="ai_integration.action_ai_integration_configuration"
sequence="10"
groups="base.group_system"/>
<!-- Provider Types menu -->
<menuitem id="menu_ai_provider_types"
name="Provider Types"
parent="menu_ai_config"
action="ai_provider_action"
sequence="10"
groups="base.group_system"/>
<!-- Provider Instances menu -->
<menuitem id="menu_ai_provider_instances"
name="Provider Instances"
parent="menu_ai_config"
action="ai_provider_instance_action"
sequence="20"/>
<!-- AI Models menu -->
<menuitem id="menu_ai_models"
name="AI Models"
parent="menu_ai_config"
action="ai_model_action"
sequence="30"/>
<!-- Statistics menu -->
<menuitem id="menu_ai_stats"
name="Model Statistics"
parent="menu_ai_config"
action="action_ai_model_stats"
sequence="40"/>
</odoo>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add AI Integration to Settings Menu -->
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
<field name="name">AI Integration</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'module': 'ai_integration'}</field>
</record>
</odoo>

View file

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Settings Action -->
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
<field name="name">AI Integration Settings</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{"module" : "ai_integration"}</field>
</record>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.ai.integration</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<div class="app_settings_block" data-string="AI Integration" string="AI Integration" data-key="ai_integration">
<h2>AI Integration</h2>
<div class="row mt16 o_settings_container" name="ai_integration_setting_container">
<div class="col-12 col-lg-6 o_setting_box" id="default_provider_settings">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<span class="o_form_label">Default Provider Configuration</span>
<div class="text-muted">
Configure the default AI provider instance and model for this company
</div>
<div class="content-group">
<div class="mt16 row">
<label for="default_provider_instance_id" class="col-lg-3 o_light_label"/>
<field name="default_provider_instance_id" options="{'no_create': True}"/>
</div>
<div class="mt16 row">
<label for="default_model_id" class="col-lg-3 o_light_label"/>
<field name="default_model_id"
options="{'no_create': True}"
invisible="default_provider_instance_id == False"/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" id="batch_processing_settings">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<span class="o_form_label">Batch Processing</span>
<div class="text-muted">
Configure batch processing parameters for AI operations
</div>
<div class="content-group">
<div class="mt16 row">
<label for="ai_batch_size" class="col-lg-3 o_light_label"/>
<field name="ai_batch_size"/>
</div>
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -1,36 +0,0 @@
{
'name': 'Ollama Integration',
'version': '1.0.0',
'category': 'Technical',
'summary': 'Integration with Ollama AI models',
'description': """
Ollama Integration
==================
This module provides integration with Ollama, allowing you to use local AI models
in your Odoo instance. Features include:
* Connection to local Ollama server
* Support for all Ollama models
* Automatic model discovery and synchronization
* Configurable model parameters
""",
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': [
'ai_integration'
],
'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',
],
'external_dependencies': {
'python': ['requests'],
},
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Ollama Provider -->
<record id="ai_provider_ollama" model="ai.provider">
<field name="name">Ollama</field>
<field name="code">ollama</field>
<field name="description">Ollama is a local AI model provider that allows you to run various open-source models locally.</field>
<field name="default_host">http://localhost:11434</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View file

@ -1,31 +0,0 @@
<?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

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Declare model inheritance -->
<record id="ollama_provider_inherit" model="ir.model.inherit">
<field name="model">ai.provider.ollama</field>
<field name="parent_id" ref="ai_integration.model_ai_provider_interface"/>
</record>
</data>
</odoo>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Register Ollama Provider -->
<record id="ai_provider_ollama" model="ai.provider">
<field name="name">Ollama</field>
<field name="code">ollama</field>
<field name="description">Local AI models powered by Ollama</field>
<field name="default_host">http://localhost:11434</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

File diff suppressed because it is too large Load diff

View file

@ -1,17 +0,0 @@
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

@ -1,27 +0,0 @@
"""Ollama AI Integration Models Package.
This package contains all the model definitions required for integrating
Ollama AI with Odoo's AI framework. The models are loaded in a specific
order to handle dependencies correctly.
Module Structure:
1. ollama_provider_mixin - Base configuration and parameter definitions
2. ollama_provider - Core Ollama API integration implementation
3. ollama_model_stats - Usage statistics and performance tracking
4. ai_provider_instance - Instance-specific configuration and management
Note: The import order is important to avoid circular dependencies.
"""
# Base Configuration
from . import ollama_provider_mixin
# Core Implementation
from . import ollama_provider
from . import ai_provider_instance
# Statistics and Monitoring
from . import ollama_model_stats
# Instance Management
from . import ai_provider_instance

View file

@ -1,153 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import requests
class OllamaAIProviderInstance(models.Model):
"""Extends the AI Provider Instance model to support Ollama-specific configuration.
This model inherits from both ai.provider.instance and ollama.provider.mixin to:
1. Add Ollama-specific fields (num_ctx, temperature, etc.)
2. Handle field visibility based on provider_type
3. Manage field cleanup when switching providers
"""
_inherit = ['ai.provider.instance', 'ollama.provider.mixin']
_name = 'ai.provider.instance'
_description = 'Ollama AI Provider Instance'
# Override provider_type to add Ollama option
provider_type = fields.Selection(
selection_add=[('ollama', 'Ollama')],
ondelete={'ollama': lambda r: r.write({'provider_type': 'none'})}
)
@api.onchange('provider_type')
def _onchange_provider_type(self):
"""Handle provider type changes.
When switching to 'ollama':
- Set default host if empty
When switching away from 'ollama':
- Clear Ollama-specific fields
"""
if self.provider_type == 'ollama':
if not self.host:
self.host = 'http://localhost:11434'
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
'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
host = fields.Char(
default='http://localhost:11434',
help='Ollama server host URL')
def test_connection(self):
"""Test the connection to the Ollama server.
This method attempts to connect to the Ollama server and verify
that it is responding correctly. It will raise a user-friendly
error if the connection fails.
Returns:
dict: Action to display success message
"""
self.ensure_one()
if self.provider_type != 'ollama':
return
try:
# Try to list models as a basic connectivity test
response = requests.get(f'{self.host}/api/tags')
response.raise_for_status()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Successfully connected to Ollama server'),
'sticky': False,
'type': 'success',
}
}
except Exception as e:
raise UserError(_('Connection test failed: %s', str(e)))
def sync_models(self):
"""Synchronize available models from the Ollama server.
This method fetches the list of available models from the Ollama
server and creates or updates the corresponding AI model records
in Odoo.
Returns:
dict: Action to display success message
"""
self.ensure_one()
if self.provider_type != 'ollama':
return
try:
# Get models from Ollama API
response = requests.get(f'{self.host}/api/tags')
response.raise_for_status()
# Parse response
models = [{
'name': model['name'],
'id': model['name'],
} for model in response.json()['models']]
for model_data in models:
# Create or update AI model record
vals = {
'name': model_data['name'],
'identifier': model_data['id'],
'provider_instance_id': self.id,
'active': True,
}
# Search for existing model
existing = self.env['ai.model'].search([
('identifier', '=', model_data['id']),
('provider_instance_id', '=', self.id)
], limit=1)
if existing:
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.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,
}}
}
except Exception as e:
raise UserError(_('Model synchronization failed: %s', str(e)))

View file

@ -1,35 +0,0 @@
from odoo import models, fields, api, _
from .ollama_provider_mixin import OllamaProviderMixin
class OllamaAIProvider(models.Model, OllamaProviderMixin):
_name = 'ai.provider.ollama'
_description = 'Ollama AI Provider'
_inherit = ['ai.provider.interface']
provider_type = fields.Selection(
selection=[('ollama', 'Ollama')],
default='ollama',
required=True,
help='Type of AI provider')
# Ollama-specific Parameters
host = fields.Char(
string='Host',
default='http://localhost:11434',
required=True,
help='Ollama server host URL')
def _get_available_models(self):
"""Get list of available models from Ollama server."""
# TODO: Implement model discovery
return []
def _generate_text(self, prompt, **kwargs):
"""Generate text using Ollama model."""
# TODO: Implement text generation
return ""
def _embed_text(self, text, **kwargs):
"""Generate embeddings for text using Ollama model."""
# TODO: Implement text embedding
return []

View file

@ -1,116 +0,0 @@
from odoo import models, fields, api
from datetime import datetime, timedelta
class OllamaModelStats(models.Model):
"""Tracks and stores daily usage statistics for Ollama AI models.
This model maintains detailed daily statistics for each Ollama model,
including request counts, token usage, response times, and error rates.
It inherits from ai.model.stats for base statistics functionality.
Key Features:
- Daily usage tracking per model
- Performance metrics collection
- Error rate monitoring
- Version tracking for model updates
Technical Details:
- One stat entry per model per day (enforced by SQL constraint)
- Automatic version tracking from Ollama API
- Aggregated statistics calculation
- Ordered by date for easy historical analysis
"""
_name = 'ollama.model.stats'
_description = 'Ollama Model Usage Statistics'
_inherit = ['ai.model.stats']
_order = 'date desc' # Most recent stats first
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
request_count = fields.Integer(string='Number of Requests', default=0)
token_count = fields.Integer(string='Total Tokens', default=0)
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
error_count = fields.Integer(string='Number of Errors', default=0)
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
_sql_constraints = [
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
]
def _update_stats(self, model, tokens, response_time, error=False):
"""Update daily statistics for a specific model.
This method handles the creation or update of daily statistics entries.
It maintains running averages and cumulative counts for various metrics.
Args:
model (ai.model): The model record being tracked
tokens (int): Number of tokens in the current request
response_time (float): Response time in milliseconds
error (bool): Whether this request resulted in an error
Technical Notes:
- Creates new stat entry if none exists for today
- Updates running averages for response time
- Fetches and stores model version from Ollama API
- Maintains cumulative counts for requests and errors
"""
today = fields.Date.context_today(self)
stats = self.search([
('model_id', '=', model.id),
('date', '=', today)
])
if not stats:
# Get model version
version = self.env['ai.provider.ollama']._get_model_info(
model.provider_instance_id,
model.identifier
).get('details', {}).get('sha256', '')[:8] # First 8 chars of SHA
stats = self.create({
'model_id': model.id,
'date': today,
'version': version
})
# Update statistics
new_count = stats.request_count + 1
new_tokens = stats.token_count + tokens
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
new_errors = stats.error_count + (1 if error else 0)
stats.write({
'request_count': new_count,
'token_count': new_tokens,
'avg_response_time': new_time,
'error_count': new_errors
})
@api.model
def get_model_stats(self, model_id, days=30):
"""Get statistics for a model over the specified number of days."""
start_date = fields.Date.today() - timedelta(days=days)
stats = self.search([
('model_id', '=', model_id),
('date', '>=', start_date)
])
return {
'daily_stats': [{
'date': stat.date,
'requests': stat.request_count,
'tokens': stat.token_count,
'response_time': stat.avg_response_time,
'errors': stat.error_count,
'version': stat.version
} for stat in stats],
'summary': {
'total_requests': sum(stat.request_count for stat in stats),
'total_tokens': sum(stat.token_count for stat in stats),
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
(sum(stat.request_count for stat in stats) if stats else 1),
'total_errors': sum(stat.error_count for stat in stats),
'versions_used': list(set(stat.version for stat in stats if stat.version))
}
}

View file

@ -1,324 +0,0 @@
import json
import logging
import requests
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OllamaProvider(models.Model):
"""Main Ollama AI Provider implementation.
This model implements the core functionality for interacting with Ollama's API,
including model management, text generation, and error handling.
Key Responsibilities:
- Model discovery and validation
- API communication and error handling
- Request formatting and response parsing
- Resource management and cleanup
Technical Details:
- Implements the ai.provider.interface for standardized AI provider integration
- Uses Ollama's HTTP API for all operations
- Handles both synchronous and asynchronous requests
- Provides detailed error messages for troubleshooting
"""
_name = 'ai.provider.ollama'
_description = 'Ollama AI Provider'
_inherit = ['ai.provider.interface']
@api.model
def _get_models(self, instance):
"""Get list of available models from Ollama server.
Args:
instance (ai.provider.instance): Provider instance to get models for
Returns:
list: List of model dictionaries with keys:
- name: Model name
- id: Model identifier
- details: Additional model metadata
Raises:
UserError: If unable to connect or retrieve models
"""
try:
response = requests.get(f"{instance.host}/api/tags")
response.raise_for_status()
models_data = response.json().get('models', [])
return [{
'name': model['name'],
'id': model['name'],
'details': model
} for model in models_data]
except requests.exceptions.RequestException as e:
raise UserError(_('Failed to connect to Ollama server: %s', str(e)))
except (KeyError, ValueError) as e:
raise UserError(_('Invalid response from Ollama server: %s', str(e)))
# API Configuration
timeout = fields.Integer(
string='Timeout',
default=30,
help='API request timeout in seconds')
def _get_provider_type(self):
return 'ollama'
def _get_model_info(self, instance, model_name):
"""Get detailed information about a specific model."""
try:
response = requests.post(
f"{instance.host}/api/show",
json={'name': model_name}
)
if response.status_code == 200:
return response.json()
else:
_logger.error(
"Failed to get model info for %s. Status: %s, Error: %s",
model_name, response.status_code, response.text
)
return None
except requests.exceptions.RequestException as e:
_logger.error("Error getting model info: %s", str(e))
return None
def pull_model(self, instance, model_name):
"""Pull a model from Ollama."""
try:
# Start the pull
response = requests.post(
f"{instance.host}/api/pull",
json={'name': model_name},
stream=True # Enable streaming for progress updates
)
if response.status_code != 200:
raise UserError(_("Failed to pull model %s: %s") %
(model_name, response.text))
# Process the streaming response
for line in response.iter_lines():
if line:
try:
progress = json.loads(line)
status = progress.get('status')
if status:
_logger.info(
"Pulling model %s: %s",
model_name, status
)
except json.JSONDecodeError:
continue
return True
except requests.exceptions.RequestException as e:
raise UserError(_("Error pulling model %s: %s") %
(model_name, str(e)))
def delete_model(self, instance, model_name):
"""Delete a model from Ollama."""
try:
response = requests.delete(
f"{instance.host}/api/delete",
json={'name': model_name}
)
if response.status_code != 200:
raise UserError(_("Failed to delete model %s: %s") %
(model_name, response.text))
return True
except requests.exceptions.RequestException as e:
raise UserError(_("Error deleting model %s: %s") %
(model_name, str(e)))
def test_connection(self, instance):
"""Test the connection to the Ollama server."""
try:
response = requests.get(f"{instance.host}/api/tags")
if response.status_code != 200:
raise UserError(_(
"Failed to connect to Ollama server. Status code: %s. Error: %s",
response.status_code, response.text
))
return True
except requests.exceptions.RequestException as e:
raise UserError(_(
"Failed to connect to Ollama server: %s", str(e)
))
def _format_chat_messages(self, messages):
"""Format chat messages for Ollama API."""
formatted_prompt = ""
for message in messages:
role = message.get('role', 'user')
content = message.get('content', '')
if role == 'system':
formatted_prompt += f"<system>{content}</system>\n"
elif role == 'assistant':
formatted_prompt += f"Assistant: {content}\n"
else: # user
formatted_prompt += f"Human: {content}\n"
return formatted_prompt.strip()
def generate_response(self, instance, model, messages, **kwargs):
"""Generate a response using the chat completion API."""
try:
# Format messages into Ollama's expected format
prompt = self._format_chat_messages(messages)
# Get model options from instance
options = instance._get_provider_options()
# Prepare the request payload
payload = {
'model': model.identifier,
'prompt': prompt,
'stream': False,
**options
}
# Make the API call
response = requests.post(
f"{instance.host}/api/generate",
json=payload
)
if response.status_code != 200:
raise UserError(_("Failed to generate response: %s") % response.text)
response_data = response.json()
generated_text = response_data.get('response', '')
# Update statistics
total_tokens = response_data.get('eval_count', 0)
response_time = response_data.get('total_duration', 0)
version = self._get_model_info(instance, model.identifier)\
.get('details', {}).get('sha256', '')[:8]
self._track_model_usage(
model, total_tokens, response_time, version=version
)
# Return the response in a standardized format
return {
'content': generated_text,
'role': 'assistant',
'metadata': {
'eval_count': response_data.get('eval_count'),
'eval_duration': response_data.get('eval_duration'),
'total_duration': response_data.get('total_duration'),
'load_duration': response_data.get('load_duration'),
}
}
except requests.exceptions.RequestException as e:
# Log error in statistics
if model:
self._track_model_usage(model, error=True)
raise UserError(_("Error generating response: %s") % str(e))
def sync_models(self, instance):
"""Synchronize available models from the Ollama server."""
self.test_connection(instance)
try:
response = requests.get(f"{instance.host}/api/tags")
models_data = response.json().get('models', [])
# Get existing models for this instance
existing_models = self.env['ai.model'].search([
('provider_instance_id', '=', instance.id)
])
existing_identifiers = {m.identifier: m for m in existing_models}
for model_data in models_data:
identifier = model_data.get('name')
if not identifier:
continue
# Get model details
model_info = self._get_model_info(instance, identifier)
model_details = model_info.get('details', {})
model_values = {
'name': identifier.title(),
'identifier': identifier,
'provider_instance_id': instance.id,
'company_id': instance.company_id.id,
'active': True,
'description': f"Ollama model: {identifier}\\nSize: {model_data.get('size', 'Unknown')}\\nModified: {model_data.get('modified', 'Unknown')}",
}
if identifier in existing_identifiers:
# Update existing model
existing_identifiers[identifier].write(model_values)
del existing_identifiers[identifier]
else:
# Create new model
self.env['ai.model'].create(model_values)
# Deactivate models that no longer exist
for model in existing_identifiers.values():
model.active = False
return True
except requests.exceptions.RequestException as e:
raise UserError(_(
"Failed to sync models from Ollama server: %s", str(e)
))
def send_message(self, message, model, **kwargs):
"""Send a message to the Ollama server."""
instance = model.provider_instance_id
try:
# Prepare the request payload
payload = {
'model': model.identifier,
'prompt': message.get('content', ''),
'stream': False,
'options': kwargs.get('options', {}),
}
# Add system message if provided
if message.get('role') == 'system':
payload['system'] = message['content']
# Send the request
response = requests.post(
f"{instance.host}/api/generate",
json=payload,
timeout=(instance.timeout or 30)
)
if response.status_code != 200:
raise UserError(_(
"Ollama server error. Status code: %s. Error: %s",
response.status_code, response.text
))
result = response.json()
return result.get('response', '')
except requests.exceptions.Timeout:
raise UserError(_("Request to Ollama server timed out"))
except requests.exceptions.RequestException as e:
raise UserError(_(
"Error communicating with Ollama server: %s", str(e)
))
except Exception as e:
_logger.error("Unexpected error in Ollama provider: %s", str(e))
raise UserError(_(
"Unexpected error in Ollama provider: %s", str(e)
))

View file

@ -1,274 +0,0 @@
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.
This mixin is designed to be inherited by models that need to interact with
the Ollama AI provider. It provides all the necessary fields and methods
for configuring and interacting with Ollama's API.
Key Features:
- Provider type selection and validation
- Context window configuration
- Advanced sampling parameters (temperature, top-k, top-p)
- Token generation controls
Technical Details:
- Inherits from ai.generation.params for base AI generation parameters
- Implements Ollama-specific API parameters
- Provides default values optimized for general use cases
"""
_name = 'ollama.provider.mixin'
_description = 'Ollama Provider Configuration Mixin'
_inherit = ['ai.generation.params']
# Model Parameters
model_name = fields.Char(
string='Model Name',
help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)',
required=True,
default='deepseek-r1:32b')
# Context Window Configuration
num_ctx = fields.Integer(
string='Context Length',
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=8192)
# Generation Parameters
temperature = fields.Float(
string='Temperature',
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.7)
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)
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(
string='Min P',
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
default=0.05,
digits=(3, 2))
repeat_penalty = fields.Float(
string='Repeat Penalty',
help='Penalty for repeating tokens. Range: [1.0 - 2.0]. Higher values make repetition less likely.',
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
)
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(
string='Random Seed',
help='Sets the random seed for generation. Range: [0 - 2147483647]. Use 0 for random.',
default=0)
num_gpu = fields.Integer(
string='Number of GPUs',
help='Number of GPUs to use for generation. Range: [0 - 8]. 0 means CPU only.',
default=1)
num_thread = fields.Integer(
string='Number of Threads',
help='Number of CPU threads to use for generation. Range: [1 - 32].',
default=8)
mirostat = fields.Selection([
('0', 'Disabled'),
('1', 'Mirostat'),
('2', 'Mirostat 2.0')],
string='Mirostat Mode',
help='Enable Mirostat sampling for controlling perplexity',
default='0')
mirostat_tau = fields.Float(
string='Mirostat Tau',
help='Mirostat target entropy. Range: [0.0 - 10.0].',
default=5.0,
digits=(3, 2))
mirostat_eta = fields.Float(
string='Mirostat Eta',
help='Mirostat learning rate. Range: [0.0 - 1.0].',
default=0.1,
digits=(3, 2))
# Ollama-specific Response Control
tfs_z = fields.Float(
string='Tail Free Sampling Z',
help='Tail free sampling parameter. Range: [0.0 - 2.0]. Higher value = more focused.',
default=1.0,
digits=(3, 2))
# System Settings
num_batch = fields.Integer(
string='Batch Size',
help='Number of prompts to batch together',
default=8)
num_keep = fields.Integer(
string='Keep Last N Tokens',
help='Number of tokens to keep from initial prompt',
default=0)
skip_special_tokens = fields.Boolean(
string='Skip Special Tokens',
help='Skip special tokens in generation',
default=True)
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
if 'provider_type' in fields_list:
defaults['provider_type'] = 'ollama'
if 'host' in fields_list and not defaults.get('host'):
defaults['host'] = 'http://localhost:11434'
return defaults
def _get_provider_options(self):
"""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,
'top_k': self.top_k,
'top_p': self.top_p,
'min_p': self.min_p,
'repeat_penalty': self.repeat_penalty,
'repeat_last_n': self.repeat_last_n,
'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,
'stream': False
}
if self.stop_sequences:
options['stop'] = [
seq.strip()
for seq in self.stop_sequences.split(',')
if seq.strip()
]
return options

View file

@ -1,5 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ollama_model_stats_user,ollama.model.stats.user,model_ollama_model_stats,base.group_user,1,0,0,0
access_ollama_model_stats_manager,ollama.model.stats.manager,model_ollama_model_stats,base.group_system,1,1,1,1
access_ai_provider_ollama_user,ai.provider.ollama.user,model_ai_provider_ollama,base.group_user,1,0,0,0
access_ai_provider_ollama_manager,ai.provider.ollama.manager,model_ai_provider_ollama,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ollama_model_stats_user ollama.model.stats.user model_ollama_model_stats base.group_user 1 0 0 0
3 access_ollama_model_stats_manager ollama.model.stats.manager model_ollama_model_stats base.group_system 1 1 1 1
4 access_ai_provider_ollama_user ai.provider.ollama.user model_ai_provider_ollama base.group_user 1 0 0 0
5 access_ai_provider_ollama_manager ai.provider.ollama.manager model_ai_provider_ollama base.group_system 1 1 1 1

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inherit AI Provider Instance List View -->
<record id="ollama_ai_provider_instance_view_list" model="ir.ui.view">
<field name="name">ai.provider.instance.list.ollama</field>
<field name="model">ai.provider.instance</field>
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_list"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//list[@name='ai_instance']" position="attributes">
<attribute name="create">true</attribute>
</xpath>
</field>
</record>
</odoo>

View file

@ -1,118 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_ollama_model_stats_list" model="ir.ui.view">
<field name="name">ollama.model.stats.list</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<list string="Model Statistics">
<field name="date"/>
<field name="model_id"/>
<field name="version"/>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_ollama_model_stats_form" model="ir.ui.view">
<field name="name">ollama.model.stats.form</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<form string="Model Statistics">
<sheet>
<group>
<group>
<field name="date"/>
<field name="model_id"/>
<field name="version"/>
</group>
<group>
<field name="request_count"/>
<field name="token_count"/>
<field name="avg_response_time"/>
<field name="error_count"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_ollama_model_stats_search" model="ir.ui.view">
<field name="name">ollama.model.stats.search</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<search string="Search Model Statistics">
<field name="model_id"/>
<field name="version"/>
<field name="date"/>
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<!-- Graph View -->
<record id="view_ollama_model_stats_graph" model="ir.ui.view">
<field name="name">ollama.model.stats.graph</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<graph string="Model Statistics" type="line" sample="1">
<field name="date" type="row"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="error_count" type="measure"/>
</graph>
</field>
</record>
<!-- Pivot View -->
<record id="view_ollama_model_stats_pivot" model="ir.ui.view">
<field name="name">ollama.model.stats.pivot</field>
<field name="model">ollama.model.stats</field>
<field name="arch" type="xml">
<pivot string="Model Statistics" sample="1">
<field name="model_id" type="row"/>
<field name="date" type="col"/>
<field name="request_count" type="measure"/>
<field name="token_count" type="measure"/>
<field name="avg_response_time" type="measure"/>
<field name="error_count" type="measure"/>
</pivot>
</field>
</record>
<!-- Action -->
<record id="action_ollama_model_stats" model="ir.actions.act_window">
<field name="name">Model Statistics</field>
<field name="res_model">ollama.model.stats</field>
<field name="view_mode">list,form,graph,pivot</field>
<field name="context">{'search_default_last_month': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No statistics recorded yet
</p>
<p>
Statistics will be automatically recorded as you use your AI models.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_ollama_model_stats"
name="Model Statistics"
parent="ai_integration.menu_ai_config"
action="action_ollama_model_stats"
sequence="30"/>
</odoo>

View file

@ -1,108 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View -->
<record id="ollama_instance_view_form" model="ir.ui.view">
<field name="name">ollama.instance.form</field>
<field name="model">ai.provider.instance</field>
<field name="type">form</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="replace">
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
</button>
</div>
</xpath>
<xpath expr="//field[@name='api_key']" position="replace">
<field name="api_key" invisible="1"/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Ollama Settings" name="ollama_settings"
invisible="provider_type != 'ollama'">
<group>
<group string="Generation Parameters">
<field name="num_ctx"/>
<field name="temperature"/>
<field name="top_p"/>
<field name="top_k"/>
<field name="repeat_penalty"/>
</group>
<group string="Advanced Settings">
<field name="stop_sequences" placeholder="Enter comma-separated stop sequences"/>
</group>
</group>
<div class="alert alert-info" role="alert" style="margin-top: 10px;">
<p><strong>Note:</strong> These settings will be used as defaults for all requests to this Ollama instance.
They can be overridden on a per-request basis.</p>
<ul>
<li><strong>Context Length:</strong> Longer context allows the model to consider more previous text but uses more memory.</li>
<li><strong>Temperature:</strong> Higher values (>1.0) make output more random, lower values make it more focused and deterministic.</li>
<li><strong>Top P:</strong> Controls diversity via nucleus sampling. Lower values (0.1) are more focused, higher values (0.9) more diverse.</li>
<li><strong>Top K:</strong> Limits the cumulative probability of tokens to sample from. Lower values are more focused.</li>
<li><strong>Repeat Penalty:</strong> Higher values (>1.0) make the model less likely to repeat itself.</li>
</ul>
</div>
</page>
</xpath>
</field>
</record>
<!-- Tree View -->
<record id="ollama_instance_view_list" model="ir.ui.view">
<field name="name">ollama.instance.list</field>
<field name="model">ai.provider.instance</field>
<field name="type">list</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="provider_type"/>
<field name="host"/>
<field name="num_ctx" optional="show"/>
<field name="temperature" optional="hide"/>
<field name="active"/>
</list>
</field>
</record>
<!-- Search View -->
<record id="ollama_instance_view_search" model="ir.ui.view">
<field name="name">ollama.instance.search</field>
<field name="model">ai.provider.instance</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_search"/>
<field name="arch" type="xml">
<xpath expr="//group" position="inside">
<filter string="Context Length" name="group_by_num_ctx"
domain="[('provider_type', '=', 'ollama')]"
context="{'group_by': 'num_ctx'}"/>
</xpath>
</field>
</record>
<!-- Action -->
<record id="action_ollama_instance" model="ir.actions.act_window">
<field name="name">Ollama Instances</field>
<field name="res_model">ai.provider.instance</field>
<field name="view_mode">list,form</field>
<field name="domain">[('provider_type', '=', 'ollama')]</field>
<field name="context">{'default_provider_type': 'ollama'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first Ollama instance
</p>
<p>
Configure Ollama instances to use local AI models in your Odoo instance.
Make sure you have Ollama installed and running on your server.
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_ollama_instance"
name="Ollama Instances"
parent="ai_integration.ai_integration_menu_config"
action="action_ollama_instance"
sequence="20"/>
</odoo>

View file

@ -1,35 +0,0 @@
{
'name': 'ChatGPT-Compatible AI Integration',
'version': '1.0',
'category': 'Technical',
'summary': 'Integration for ChatGPT-compatible AI providers',
'description': """
ChatGPT-Compatible AI Integration
================================
This module provides integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc), offering:
* ChatGPT-compatible provider implementation
* Model synchronization
* Advanced parameter configuration
* Usage statistics tracking
* Support for multiple providers using the ChatGPT API format
""",
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': [
'ai_integration',
'openwebui_integration'
],
'data': [
'security/ir.model.access.csv',
'views/chatgpt_instance_views.xml',
'data/chatgpt_provider.xml',
],
'external_dependencies': {
'python': ['requests'],
},
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ChatGPT-Compatible Provider -->
<record id="ai_provider_chatgpt" model="ai.provider">
<field name="name">ChatGPT-Compatible</field>
<field name="code">chatgpt</field>
<field name="instance_model">chatgpt.ai.instance</field>
<field name="provider_model">ai.provider.chatgpt</field>
<field name="description">Integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc).</field>
<field name="website">https://platform.openai.com/docs/api-reference/chat</field>
</record>
</data>
</odoo>

View file

@ -1,2 +0,0 @@
from . import chatgpt_provider
from . import chatgpt_instance

View file

@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class ChatGPTInstance(models.Model):
_name = 'chatgpt.ai.instance'
_description = 'ChatGPT-Compatible Instance Configuration'
_inherit = ['ai.provider.instance', 'ai.generation.params']
provider_type = fields.Selection(
selection_add=[('chatgpt', 'ChatGPT-Compatible')],
ondelete={'chatgpt': 'cascade'})
# ChatGPT API Parameters
presence_penalty = fields.Float(
string='Presence Penalty',
help='Penalty for new tokens based on their presence in text. Range: [-2.0 - 2.0].',
default=0.0,
digits=(3, 2))
frequency_penalty = fields.Float(
string='Frequency Penalty',
help='Penalty for new tokens based on their frequency in text. Range: [-2.0 - 2.0].',
default=0.0,
digits=(3, 2))
# Provider Settings
def _get_provider_options(self):
"""Get ChatGPT-compatible options for API calls."""
self.ensure_one()
options = {
'temperature': self.temperature,
'top_p': self.top_p,
'max_tokens': self.max_tokens,
'presence_penalty': self.presence_penalty,
'frequency_penalty': self.frequency_penalty,
'stream': self.stream_response,
}
if self.stop_sequences:
options['stop'] = [
seq.strip()
for seq in self.stop_sequences.split(',')
]
return options

View file

@ -1,163 +0,0 @@
# -*- coding: utf-8 -*-
import json
import logging
import requests
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ChatGPTProvider(models.AbstractModel):
_name = 'ai.provider.chatgpt'
_description = 'ChatGPT-Compatible AI Provider'
_inherit = ['ai.provider']
def _get_provider_type(self):
return 'chatgpt'
def test_connection(self, instance):
"""Test the connection to the OpenWebUI server."""
try:
response = requests.get(f"{instance.host}/api/v1/models")
if response.status_code != 200:
raise UserError(_(
"Failed to connect to AI server. Status code: %s. Error: %s",
response.status_code, response.text
))
return True
except requests.exceptions.RequestException as e:
raise UserError(_(
"Failed to connect to AI server: %s", str(e)
))
def sync_models(self, instance):
"""Synchronize available models from the OpenWebUI server."""
self.test_connection(instance)
try:
# Get models from provider
response = requests.get(f"{instance.host}/api/v1/models")
models_data = response.json()
# Get existing models for this instance
existing_models = self.env['ai.model'].search([
('provider_instance_id', '=', instance.id)
])
existing_identifiers = {m.identifier: m for m in existing_models}
for model_data in models_data:
identifier = model_data.get('id')
if not identifier:
continue
# Get model details
model_info = self._get_model_info(instance, identifier)
model_details = model_info.get('details', {})
model_values = {
'name': model_data.get('name', identifier),
'identifier': identifier,
'description': model_details.get('description', ''),
'version': model_details.get('version', ''),
'provider_instance_id': instance.id,
}
if identifier in existing_identifiers:
# Update existing model
existing_identifiers[identifier].write(model_values)
else:
# Create new model
self.env['ai.model'].create(model_values)
return True
except requests.exceptions.RequestException as e:
raise UserError(_("Error synchronizing models: %s") % str(e))
def _get_model_info(self, instance, model_name):
"""Get detailed information about a specific model."""
try:
response = requests.get(
f"{instance.host}/api/v1/models/{model_name}/info"
)
if response.status_code == 200:
return response.json()
else:
_logger.error(
"Failed to get model info for %s. Status: %s, Error: %s",
model_name, response.status_code, response.text
)
return {}
except requests.exceptions.RequestException as e:
_logger.error("Error getting model info: %s", str(e))
return {}
def _format_chat_messages(self, messages):
"""Format chat messages for OpenWebUI API."""
formatted_messages = []
for message in messages:
role = message.get('role', 'user')
content = message.get('content', '')
formatted_messages.append({
'role': role,
'content': content
})
return formatted_messages
def generate_response(self, instance, model, messages, **kwargs):
"""Generate a response using the chat completion API."""
try:
# Format messages for OpenWebUI
formatted_messages = self._format_chat_messages(messages)
# Get model options from instance
options = instance._get_provider_options()
# Prepare the request payload
payload = {
'model': model.identifier,
'messages': formatted_messages,
**options
}
# Make the API call
response = requests.post(
f"{instance.host}/api/v1/chat/completions",
json=payload
)
if response.status_code != 200:
raise UserError(_("Failed to generate response: %s") % response.text)
response_data = response.json()
generated_text = response_data.get('choices', [{}])[0].get('message', {}).get('content', '')
# Update statistics
total_tokens = response_data.get('usage', {}).get('total_tokens', 0)
response_time = response_data.get('response_ms', 0)
version = self._get_model_info(instance, model.identifier)\
.get('details', {}).get('version', '')
self._track_model_usage(
model, total_tokens, response_time, version=version
)
# Return the response in a standardized format
return {
'content': generated_text,
'role': 'assistant',
'metadata': {
'total_tokens': total_tokens,
'response_time': response_time,
'model_version': version,
'usage': response_data.get('usage', {})
}
}
except requests.exceptions.RequestException as e:
# Log error in statistics
if model:
self._track_model_usage(model, error=True)
raise UserError(_("Error generating response: %s") % str(e))

View file

@ -1,3 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_openwebui_ai_instance_user,openwebui.ai.instance user,model_openwebui_ai_instance,base.group_user,1,0,0,0
access_openwebui_ai_instance_system,openwebui.ai.instance system,model_openwebui_ai_instance,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_ai_instance_user openwebui.ai.instance user model_openwebui_ai_instance base.group_user 1 0 0 0
3 access_openwebui_ai_instance_system openwebui.ai.instance system model_openwebui_ai_instance base.group_system 1 1 1 1

View file

@ -1,106 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View -->
<record id="view_chatgpt_ai_instance_form" model="ir.ui.view">
<field name="name">chatgpt.ai.instance.form</field>
<field name="model">chatgpt.ai.instance</field>
<field name="arch" type="xml">
<form string="ChatGPT-Compatible Instance">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="e.g. Production OpenWebUI/ChatGPT"/></h1>
</div>
<group>
<group string="Connection">
<field name="host" placeholder="e.g. http://localhost:8080"/>
<field name="api_key"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="is_default"/>
</group>
<group string="Basic Generation">
<field name="temperature"/>
<field name="top_p"/>
<field name="max_tokens"/>
<field name="presence_penalty"/>
<field name="frequency_penalty"/>
</group>
</group>
<group string="Advanced Settings">
<group>
<field name="stop_sequences" placeholder="e.g. END,STOP,DONE"/>
<field name="timeout"/>
<field name="retry_count"/>
</group>
<group>
<field name="stream_response"/>
</group>
</group>
<group string="Models" attrs="{'invisible': [('id', '=', False)]}">
<field name="model_ids" nolabel="1">
<tree>
<field name="name"/>
<field name="identifier"/>
<field name="version"/>
<field name="description"/>
</tree>
</field>
</group>
</sheet>
</form>
</field>
</record>
<!-- Tree View -->
<record id="view_chatgpt_ai_instance_tree" model="ir.ui.view">
<field name="name">chatgpt.ai.instance.tree</field>
<field name="model">chatgpt.ai.instance</field>
<field name="arch" type="xml">
<tree string="ChatGPT-Compatible Instances">
<field name="name"/>
<field name="host"/>
<field name="is_default"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="view_chatgpt_ai_instance_search" model="ir.ui.view">
<field name="name">chatgpt.ai.instance.search</field>
<field name="model">chatgpt.ai.instance</field>
<field name="arch" type="xml">
<search string="Search ChatGPT-Compatible Instances">
<field name="name"/>
<field name="host"/>
<field name="company_id" groups="base.group_multi_company"/>
<filter string="Default Instance" name="is_default" domain="[('is_default', '=', True)]"/>
<group expand="0" string="Group By">
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_chatgpt_ai_instance" model="ir.actions.act_window">
<field name="name">ChatGPT-Compatible Instances</field>
<field name="res_model">chatgpt.ai.instance</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first ChatGPT-compatible instance
</p>
<p>
Configure instances to connect to ChatGPT-compatible servers (OpenWebUI, ChatGPT, etc).
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_chatgpt_ai_instance"
name="ChatGPT-Compatible"
parent="ai_integration.menu_ai_integration_config"
action="action_chatgpt_ai_instance"
sequence="20"/>
</odoo>

View file

@ -1,22 +0,0 @@
{
"name": "Stock Quant Apply Single Inventory with Reason",
"author": "Bemade Inc.",
"version": "18.0.1.0.0",
"category": "Inventory/Inventory",
"summary": "Add reason dialog when applying single inventory adjustment",
"description": """
This module modifies the behavior of the Apply button on stock quants to show
the same reason dialog as when using Apply All, ensuring consistency in
inventory adjustment tracking.
""",
"author": "Bemade",
"website": "https://bemade.org",
"depends": ["stock"],
"data": [
"views/stock_quant_views.xml",
],
"installable": True,
"auto_install": False,
"license": "LGPL-3",
"application": False,
}

View file

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

View file

@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
class StockQuant(models.Model):
_inherit = 'stock.quant'
def action_apply_single_inventory(self):
"""
New method that opens the same reason dialog as action_apply_all
for single inventory adjustments.
"""
ctx = dict(self.env.context or {}, default_quant_ids=self.ids)
view = self.env.ref('stock.stock_inventory_adjustment_name_form_view', False)
return {
'name': _('Inventory Adjustment Reference / Reason'),
'type': 'ir.actions.act_window',
'views': [(view.id, 'form')],
'res_model': 'stock.inventory.adjustment.name',
'target': 'new',
'context': ctx,
}

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_quant_tree_inventory_editable_inherit" model="ir.ui.view">
<field name="name">stock.quant.tree.inventory.editable.inherit</field>
<field name="model">stock.quant</field>
<field name="inherit_id" ref="stock.view_stock_quant_tree_inventory_editable"/>
<field name="arch" type="xml">
<button name="action_apply_inventory" position="attributes">
<attribute name="name">action_apply_single_inventory</attribute>
</button>
</field>
</record>
</odoo>

View file

@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizard

View file

@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
{
"name": "Batch Picking - Create One Bill",
"version": "18.0.1.0.1",
"category": "Inventory/Purchase",
"summary": "Créer une seule facture fournisseur pour tous les bons de commande d'un batch picking",
"description": """
Ce module permet de générer une seule facture fournisseur pour tous les bons de commande
associés à un batch picking.
Fonctionnalités:
- Option pour initialiser les quantités à zéro lors de la création d'un batch
- Bouton pour créer une facture groupée à partir d'un batch de transferts
- Validation que tous les bons de commande proviennent du même fournisseur
- Suivi des factures créées directement depuis le batch
""",
"author": "Pneumac",
"website": "https://www.pneumac.ca",
"depends": [
"stock_picking_batch",
"purchase",
"purchase_stock",
"account",
"account_reports",
],
"data": [
"wizard/stock_picking_to_batch_views.xml",
"views/stock_picking_batch_views.xml",
],
"installable": True,
"application": False,
"auto_install": False,
"license": "LGPL-3",
}

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
from . import stock_picking_batch

View file

@ -1,167 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, Command, _
from odoo.exceptions import ValidationError
class StockPickingBatch(models.Model):
_inherit = "stock.picking.batch"
purchase_order_ids = fields.Many2many(
"purchase.order",
string="Bons de commande",
compute="_compute_purchase_orders",
compute_sudo=True,
)
partner_ids = fields.Many2many(
"res.partner",
string="Fournisseur",
compute="_compute_partner",
compute_sudo=True,
)
@api.model_create_multi
def create(self, vals_list):
# Handle zero_quantity_default from context if not explicitly set in vals
for vals in vals_list:
if (
"zero_quantity_default" not in vals
and self.env.context.get("default_zero_quantity_default") is not None
):
vals["zero_quantity_default"] = self.env.context.get(
"default_zero_quantity_default"
)
return super().create(vals_list)
zero_quantity_default = fields.Boolean(
string="Quantités à zéro par défaut",
default=True,
help="Initialiser les quantités à zéro lors de la création du batch",
)
invoice_ids = fields.Many2many(
"account.move",
string="Factures associées",
compute="_compute_invoice_ids",
compute_sudo=True,
)
invoice_count = fields.Integer(
string="Nombre de factures",
compute="_compute_invoice_count",
compute_sudo=True,
)
# Field computation methods
@api.depends("picking_ids.move_ids.purchase_line_id.order_id")
def _compute_purchase_orders(self):
for batch in self:
batch.purchase_order_ids = (
batch.picking_ids.move_ids.purchase_line_id.order_id
)
@api.depends("purchase_order_ids")
def _compute_partner(self):
for wizard in self:
wizard.partner_ids = wizard.purchase_order_ids.mapped("partner_id")
def _prepare_move_line_vals(self, **kwargs):
vals = super()._prepare_move_line_vals(**kwargs)
if self.zero_quantity_default:
vals["quantity"] = 0.0
return vals
@api.depends("move_line_ids.move_id.purchase_line_id.order_id.invoice_ids")
def _compute_invoice_ids(self):
for batch in self:
batch.invoice_ids = (
batch.move_line_ids.move_id.purchase_line_id.invoice_lines.move_id
)
@api.depends("invoice_ids")
def _compute_invoice_count(self):
for batch in self:
batch.invoice_count = len(batch.invoice_ids)
# Actions
def action_view_invoices(self):
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id(
"account.action_move_in_invoice_type"
)
if self.invoice_count == 1:
action["views"] = [(False, "form")]
action["res_id"] = self.invoice_ids.id
else:
action["domain"] = [("id", "in", self.invoice_ids.ids)]
return action
def action_confirm(self):
"""Override to set zero quantity on move lines at confirmation of the batch"""
res = super().action_confirm()
self.filtered("zero_quantity_default").move_line_ids.write({"quantity": 0})
return res
def action_create_bill(self):
self.ensure_one()
if not self.purchase_order_ids:
raise ValidationError(_("No purchase orders found in this batch."))
if len(self.partner_ids) > 1:
raise ValidationError(_("The batch must have only one supplier."))
bill = self.env["account.move"].create(self._get_bill_values())
return self._get_view_bill_action(bill)
# Helpers
def _get_view_bill_action(self, bill):
action = self.env["ir.actions.act_window"]._for_xml_id(
"account.action_move_in_invoice_type"
)
action["views"] = [(False, "form")]
action["res_id"] = bill.id
return action
def _get_currency_id(self):
currency_id = self.purchase_order_ids.mapped("currency_id")
if len(currency_id) > 1:
raise UserError(_("The selected receipts do not have the same currency."))
return currency_id.id
def _get_bill_values(self):
"""Build a dictionary of the values for the vendor bill to create."""
company_id = self.company_id.id
partner_id = self.partner_ids.id
invoice_date = self.scheduled_date
invoice_origin = ", ".join(self.purchase_order_ids.mapped("name"))
move_line_ids = self._get_line_values()
currency_id = self._get_currency_id()
return {
"company_id": company_id,
"partner_id": partner_id,
"move_type": "in_invoice",
"invoice_date": invoice_date,
"invoice_origin": invoice_origin,
"currency_id": currency_id,
"line_ids": move_line_ids,
}
def _get_line_values(self):
"""For each of the stock.move.line in the batch, build a dictionary of values for the invoice line to match."""
line_vals = []
for move_line in self.move_line_ids:
purchase_line_id = move_line.move_id.purchase_line_id
line_vals.append(
Command.create(
{
"product_id": move_line.product_id.id,
"quantity": move_line.quantity,
"price_unit": purchase_line_id.price_unit,
"discount": purchase_line_id.discount,
"purchase_line_id": purchase_line_id.id,
}
)
)
return line_vals

View file

@ -1,693 +0,0 @@
# Spécifications du module batch_picking_create_one_bill
## Objectif
Créer un module Odoo 18.0 permettant de générer une seule facture fournisseur (vendor bill) consolidée pour tous les bons de commande (Purchase Orders) associés à un batch picking, en utilisant la méthode standard de création de factures d'Odoo basée sur les réceptions.
## Fonctionnalités principales
### 1. Modification du comportement des Batch Pickings
#### Héritage du modèle `stock.picking.batch`
##### Champs ajoutés
```python
# Dans stock_picking_batch.py
class StockPickingBatch(models.Model):
_inherit = 'stock.picking.batch'
zero_quantity_default = fields.Boolean(
string='Quantités à zéro par défaut',
default=True,
help='Initialiser les quantités à zéro lors de la création du batch'
)
invoice_count = fields.Integer(
compute='_compute_invoice_count',
string='Nombre de factures'
)
invoice_ids = fields.Many2many(
'account.move',
string='Factures associées',
compute='_compute_invoice_ids',
store=True
)
```
##### Surcharge des méthodes
###### Initialisation des quantités
```python
def _prepare_move_line_vals(self, **kwargs):
vals = super()._prepare_move_line_vals(**kwargs)
if self.zero_quantity_default:
vals['quantity'] = 0.0
return vals
```
###### Calcul des factures associées
```python
@api.depends('picking_ids', 'picking_ids.purchase_id.invoice_ids')
def _compute_invoice_ids(self):
for batch in self:
invoices = batch.picking_ids.mapped('purchase_id.invoice_ids')
batch.invoice_ids = invoices
batch.invoice_count = len(invoices)
```
##### Actions et boutons
```python
def action_view_invoices(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id(
'account.action_move_in_invoice_type'
)
if self.invoice_count == 1:
action['views'] = [(False, 'form')]
action['res_id'] = self.invoice_ids.id
else:
action['domain'] = [('id', 'in', self.invoice_ids.ids)]
return action
```
#### Modification des vues
##### Vue formulaire du batch picking
```xml
<record id="view_picking_batch_form_inherit" model="ir.ui.view">
<field name="name">stock.picking.batch.form.inherit</field>
<field name="model">stock.picking.batch</field>
<field name="inherit_id" ref="stock.view_picking_batch_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="%(action_create_batch_bill_wizard)d"
string="Créer Facture"
type="action"
class="btn-primary"
attrs="{'invisible': [('state', '!=', 'done')]}"/>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button"
name="action_view_invoices"
type="object"
icon="fa-pencil-square-o">
<field name="invoice_count" widget="statinfo" string="Factures"/>
</button>
</xpath>
<xpath expr="//group[@name='group_misc']" position="inside">
<field name="zero_quantity_default"/>
</xpath>
</field>
</record>
```
#### Gestion des mouvements de stock
##### Modification du comportement des move lines
```python
def _update_move_lines_values(self, moves):
res = super()._update_move_lines_values(moves)
if self.zero_quantity_default:
for move in moves:
for line in move.move_line_ids:
line.qty_done = 0.0
return res
```
##### Validation des quantités
```python
def _check_received_quantities(self):
self.ensure_one()
for move in self.move_lines:
if move.quantity_done > move.product_uom_qty:
raise ValidationError(
_('La quantité reçue ne peut pas être supérieure '
'à la quantité commandée pour le produit %s')
% move.product_id.display_name
)
```
### 2. Critères de regroupement
- Regroupement uniquement des PO du même fournisseur
- Facturation basée uniquement sur les quantités réellement reçues dans le batch picking
- Les lignes de PO non reçues ne seront pas incluses dans la facture
### 2. Processus de création de facture
- Un bouton dédié sera ajouté sur le batch picking après sa validation
- Pour chaque PO du batch :
1. Utilisation de la méthode standard d'Odoo `action_create_invoice` pour créer les factures basées sur les réceptions
2. Les factures sont créées selon la politique 'Sur réception' (On received quantities)
3. Création automatique de factures partielles pour les quantités reçues
- Fusion automatique de toutes les factures créées en une seule facture
1. Regroupement des lignes par produit
2. Conservation des liens avec les PO d'origine
3. Suppression des factures individuelles après fusion
### 3. Gestion des cas particuliers
- Vérification des quantités reçues vs commandées
- Validation que toutes les réceptions sont bien effectuées avant la création de la facture
- Gestion des écarts entre les quantités commandées et reçues
### 4. Sécurité et droits d'accès
- Restriction aux utilisateurs du groupe 'Invoicing user'
- Vérification des droits d'accès sur les documents liés (PO, réceptions)
### 5. Interface utilisateur
- Ajout d'un bouton sur la vue form du batch picking
- Rapports spécifiques pour le suivi des factures groupées [À PRÉCISER]
- Messages de confirmation/erreur clairs pour l'utilisateur
### 6. Aspects techniques
- Pas de règles spécifiques pour la numérotation des factures (utilisation du système standard)
- Intégration avec les modules stock_picking_batch et purchase
- Respect des règles de facturation basées sur les réceptions
## Workflow
1. Création et traitement normal du batch picking
2. Validation du batch picking
3. Utilisation du nouveau bouton pour créer la facture groupée
4. Pour chaque PO impliqué dans le batch picking :
- Utilisation de la méthode standard `action_create_invoice` d'Odoo
- Création automatique des factures basées sur les quantités reçues
- Les quantités non reçues restent en attente de facturation
5. Fusion automatique de toutes les factures créées en une seule facture fournisseur
- Regroupement des lignes par produit
- Maintien des références aux PO d'origine
- Conservation des taxes et comptes analytiques
- Suppression des factures individuelles après fusion réussie
6. Traitement normal de la facture fusionnée
## Impact sur les processus existants
- Les utilisateurs devront entrer manuellement les quantités reçues au lieu de les ajuster
- Réduction des erreurs de réception dues aux quantités pré-remplies
- Meilleure traçabilité des quantités réellement reçues vs commandées
## Détails techniques d'implémentation
### 1. Modification du comportement des Batch Pickings
- Héritage du modèle `stock.picking.batch`
- Surcharge de la méthode de création pour initialiser les quantités à 0
- Modification des champs de quantité dans `stock.move.line`
### 2. Gestion des factures
#### Wizard de création de facture (`create_merged_bill.py`)
##### 1. Modèles
###### Modèle principal : `batch.picking.create.bill.wizard`
- Champs principaux :
- `batch_id`: Many2one vers stock.picking.batch (readonly)
- Batch picking source pour la création de la facture
- `purchase_order_ids`: Many2many vers purchase.order (computed, readonly)
- Liste des PO associés au batch
- Calculé automatiquement depuis le batch
- `partner_id`: Many2one vers res.partner (computed, readonly)
- Fournisseur commun à tous les PO
- Vérification d'unicité du fournisseur
- `invoice_ids`: Many2many vers account.move (computed)
- Factures créées par le processus standard d'Odoo
- Utilisé temporairement avant la fusion
- `merged_invoice_id`: Many2one vers account.move
- Facture finale fusionnée
- `preview_available`: Boolean (computed)
- Indique si la prévisualisation est possible
###### Modèle temporaire : `batch.picking.create.bill.line.preview`
- Champs :
- `wizard_id`: Many2one vers le wizard principal
- `product_id`: Many2one vers product.product
- `description`: Char (nom du produit + description)
- `quantity`: Float (quantité reçue)
- `uom_id`: Many2one vers uom.uom
- `price_unit`: Float
- `price_subtotal`: Float
- `purchase_line_ids`: Many2many vers purchase.order.line
- `picking_ids`: Many2many vers stock.picking
##### 2. Fonctions principales
###### Calculs et vérifications
- `_compute_purchase_orders()`:
```python
def _compute_purchase_orders(self):
for wizard in self:
pickings = wizard.batch_id.picking_ids
purchase_orders = pickings.mapped('purchase_id')
wizard.purchase_order_ids = purchase_orders
```
- `_compute_partner()`:
```python
def _compute_partner(self):
for wizard in self:
partners = wizard.purchase_order_ids.mapped('partner_id')
if len(partners) != 1:
raise ValidationError(_('Tous les PO doivent avoir le même fournisseur'))
wizard.partner_id = partners
```
###### Préparation des données
- `_prepare_invoice_lines()`:
- Regroupe les lignes par produit
- Calcule les quantités reçues
- Vérifie la cohérence des prix
- Génère les lignes de facture
- `_prepare_preview_lines()`:
- Crée les lignes de prévisualisation
- Affiche les détails des réceptions
- Calcule les sous-totaux
###### Actions
- `action_create_bill()`:
1. Vérifications préalables
2. Création des factures par PO
3. Fusion des factures
4. Liaison avec le batch picking
5. Retour vers la facture créée
##### 3. Interface utilisateur
###### Vue formulaire principale
```xml
<form>
<header>
<button name="action_create_bill"
string="Créer Facture"
type="object"
class="btn-primary"
attrs="{'invisible': [('preview_available', '=', False)]}"/>
<button special="cancel" string="Annuler" class="btn-secondary"/>
</header>
<sheet>
<group>
<group>
<field name="batch_id"/>
<field name="partner_id"/>
<field name="currency_id"/>
</group>
<group>
<field name="amount_total"/>
<field name="preview_available" invisible="1"/>
</group>
</group>
<notebook>
<page string="Ordres d'achat">
<field name="purchase_order_ids"/>
</page>
<page string="Prévisualisation des lignes">
<field name="preview_line_ids">
<tree>
<field name="product_id"/>
<field name="description"/>
<field name="quantity"/>
<field name="uom_id"/>
<field name="price_unit"/>
<field name="price_subtotal"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
```
##### 4. Messages et Validations
###### Messages d'erreur
- Fournisseur différent :
```python
_('Les bons de commande sélectionnés ont des fournisseurs différents : %s')
```
- Batch non validé :
```python
_('Le batch picking doit être validé avant de créer la facture')
```
- Quantités invalides :
```python
_('Certaines lignes ont des quantités reçues invalides')
```
###### Validations automatiques
- Vérification de l'état du batch
- Contrôle des quantités reçues
- Vérification des devises
- Validation des droits d'accès
### 3. Modifications de l'interface
#### Modifications des vues principales
##### 1. Vue formulaire du batch picking (`stock_picking_batch_views.xml`)
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Héritage de la vue form du batch picking -->
<record id="view_picking_batch_form_inherit" model="ir.ui.view">
<field name="name">stock.picking.batch.form.inherit</field>
<field name="model">stock.picking.batch</field>
<field name="inherit_id" ref="stock.view_picking_batch_form"/>
<field name="arch" type="xml">
<!-- Ajout du bouton de création de facture -->
<xpath expr="//header" position="inside">
<button name="%(action_create_batch_bill_wizard)d"
string="Créer Facture"
type="action"
class="btn-primary"
attrs="{'invisible': [('state', '!=', 'done')]}"/>
</xpath>
<!-- Ajout du smart button pour les factures -->
<div name="button_box" position="inside">
<button class="oe_stat_button"
name="action_view_invoices"
type="object"
icon="fa-pencil-square-o">
<field name="invoice_count" widget="statinfo" string="Factures"/>
</button>
</div>
<!-- Ajout de l'option quantités à zéro -->
<group name="group_misc" position="inside">
<field name="zero_quantity_default"/>
</group>
</field>
</record>
<!-- Vue tree des batch pickings -->
<record id="view_picking_batch_tree_inherit" model="ir.ui.view">
<field name="name">stock.picking.batch.tree.inherit</field>
<field name="model">stock.picking.batch</field>
<field name="inherit_id" ref="stock.view_picking_batch_tree"/>
<field name="arch" type="xml">
<field name="state" position="after">
<field name="invoice_count"/>
</field>
</field>
</record>
<!-- Vue search des batch pickings -->
<record id="view_picking_batch_search_inherit" model="ir.ui.view">
<field name="name">stock.picking.batch.search.inherit</field>
<field name="model">stock.picking.batch</field>
<field name="inherit_id" ref="stock.view_picking_batch_search"/>
<field name="arch" type="xml">
<filter name="to_process" position="after">
<filter string="Non facturé"
name="not_invoiced"
domain="[('invoice_count', '=', 0)]"/>
<filter string="Facturé"
name="invoiced"
domain="[('invoice_count', '>', 0)]"/>
</filter>
</field>
</record>
</odoo>
```
##### 2. Vue formulaire de la facture (`account_move_views.xml`)
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_form_inherit" model="ir.ui.view">
<field name="name">account.move.form.inherit</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<!-- Ajout des références aux batch pickings -->
<xpath expr="//field[@name='invoice_origin']" position="after">
<field name="batch_picking_ids"
widget="many2many_tags"
readonly="1"
attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}"/>
</xpath>
</field>
</record>
</odoo>
```
#### Actions et menus
##### Actions du wizard
```xml
<record id="action_create_batch_bill_wizard" model="ir.actions.act_window">
<field name="name">Créer Facture Fournisseur</field>
<field name="res_model">batch.picking.create.bill.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_stock_picking_batch"/>
<field name="binding_view_types">form</field>
</record>
```
#### Sécurité et droits d'accès
##### Groupes et droits
```xml
<record id="group_batch_invoice" model="res.groups">
<field name="name">Création de factures depuis batch picking</field>
<field name="category_id" ref="base.module_category_inventory"/>
<field name="implied_ids" eval="[(4, ref('account.group_account_invoice'))]"/>
</record>
```
##### Règles d'accès
```csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_batch_bill_wizard,access.batch.picking.create.bill.wizard,model_batch_picking_create_bill_wizard,group_batch_invoice,1,1,1,0
access_batch_bill_line_preview,access.batch.picking.create.bill.line.preview,model_batch_picking_create_bill_line_preview,group_batch_invoice,1,1,1,1
```
### 4. Points techniques clés
#### Manifest du module
```python
{
'name': 'Batch Picking - Create One Bill',
'version': '18.0.1.0.0',
'category': 'Inventory/Purchase',
'summary': 'Créer une seule facture pour tous les PO dun batch picking',
'author': 'Pneumac',
'website': 'https://www.pneumac.com',
'license': 'LGPL-3',
'depends': [
'stock_picking_batch',
'purchase',
'account',
],
'data': [
'security/security_groups.xml',
'security/ir.model.access.csv',
'views/stock_picking_batch_views.xml',
'views/account_move_views.xml',
'wizard/create_merged_bill_views.xml',
],
'demo': [],
'installable': True,
'auto_install': False,
'application': False,
}
```
#### Structure complète des fichiers
```
batch_picking_create_one_bill/
├── __init__.py # Import des sous-modules
├── __manifest__.py # Configuration du module
├── models/ # Définition des modèles
│ ├── __init__.py
│ ├── stock_picking_batch.py # Extension du modèle batch
│ └── account_move.py # Extension du modèle facture
├── wizard/ # Assistant de création de facture
│ ├── __init__.py
│ ├── create_merged_bill.py # Logique du wizard
│ └── create_merged_bill_views.xml # Vues du wizard
├── views/ # Vues des modèles
│ ├── stock_picking_batch_views.xml
│ └── account_move_views.xml
├── security/ # Sécurité et droits d'accès
│ ├── ir.model.access.csv # Règles d'accès aux modèles
│ └── security_groups.xml # Définition des groupes
├── static/ # Ressources statiques
│ └── description/
│ └── icon.png # Icône du module
└── i18n/ # Traductions
└── fr.po # Traduction française
```
#### Points techniques spécifiques
##### 1. Gestion des hooks
```python
# Dans stock_picking_batch.py
from odoo import models, fields, api, _
class StockPickingBatch(models.Model):
_inherit = 'stock.picking.batch'
@api.model
def create(self, vals):
# Hook de création pour initialiser les quantités à 0
res = super().create(vals)
if res.zero_quantity_default:
res._reset_quantities()
return res
def write(self, vals):
# Hook d'écriture pour gérer les modifications
res = super().write(vals)
if 'zero_quantity_default' in vals:
self._handle_quantity_change()
return res
```
##### 2. Gestion des contraintes
```python
@api.constrains('picking_ids', 'picking_ids.purchase_id')
def _check_purchase_orders(self):
for batch in self:
purchases = batch.picking_ids.mapped('purchase_id')
partners = purchases.mapped('partner_id')
if len(partners) > 1:
raise ValidationError(_(
'Le batch picking ne peut contenir que des PO '
'du même fournisseur.'
))
```
##### 3. Performance et optimisation
```python
# Dans create_merged_bill.py
from odoo.tools.profiler import profile
@profile
def _prepare_invoice_lines(self):
# Optimisation avec prefetch
products = self.env['product.product'].browse(
self.purchase_order_ids.mapped('order_line.product_id.id')
).exists()
products.read(['name', 'type', 'invoice_policy'])
# Traitement par lots
moves_by_product = {}
for move in self.batch_id.move_lines:
if move.product_id not in moves_by_product:
moves_by_product[move.product_id] = self.env['stock.move']
moves_by_product[move.product_id] |= move
```
##### 4. Gestion des erreurs
```python
class BatchBillError(Exception):
""" Erreur spécifique pour la création de facture depuis batch """
pass
def _handle_bill_creation_error(self, error):
if isinstance(error, BatchBillError):
# Erreur métier spécifique
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Erreur'),
'message': str(error),
'type': 'danger',
}
}
# Autres erreurs
raise error
```
##### 5. Tests unitaires
```python
# Dans tests/test_batch_bill.py
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestBatchBill(TransactionCase):
def setUp(self):
super().setUp()
# Préparation des données de test
def test_create_bill_from_batch(self):
# Test de création de facture
def test_zero_quantity_default(self):
# Test de l'initialisation des quantités
def test_merge_bills(self):
# Test de la fusion des factures
```
- Gestion des états des documents (draft, done, etc.)
- Traçabilité complète via les champs related et stored
- Gestion des droits d'accès via les groupes de sécurité
### 5. Améliorations suggérées
#### 1. Fonctionnalités supplémentaires
- **Annulation en masse** :
- Possibilité d'annuler la facture créée et de revenir à l'état précédent
- Traçabilité des annulations dans l'historique
- **Rapports et analyses** :
- Rapport de réconciliation PO/Réceptions/Factures
#### 2. Améliorations techniques
- **Cache et performance** :
- Mise en cache des calculs fréquents
- Optimisation des requêtes SQL
- Indexation des champs clés
- **Gestion des erreurs avancée** :
- Journal d'erreurs détaillé
- Mécanisme de reprise sur erreur
- Notifications utilisateur améliorées
- **Tests automatisés** :
- Tests d'intégration avec scénarios complexes
- Tests de performance
- Tests de régression
#### 3. Améliorations UX
- **Interface utilisateur** :
- Vue kanban pour les batch pickings avec statut de facturation
- Filtres et groupements avancés
- Aperçu rapide des informations clés
- **Processus utilisateur** :
- Assistant de validation en plusieurs étapes
- Suggestions automatiques basées sur l'historique
- Messages d'aide contextuels
#### 4. Personnalisation
- **Configuration avancée** :
- Paramètres par entreprise
### 5. Structure des fichiers
```
batch_picking_create_one_bill/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ ├── stock_picking_batch.py
│ └── account_move.py
├── wizard/
│ ├── __init__.py
│ └── create_merged_bill.py
├── views/
│ ├── stock_picking_batch_views.xml
│ └── account_move_views.xml
├── security/
│ └── ir.model.access.csv
└── data/
└── security_groups.xml
```
## Dépendances
- stock_picking_batch
- purchase
- account
## Version
- Odoo 18.0

View file

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

View file

@ -1,286 +0,0 @@
# -*- coding: utf-8 -*-
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.exceptions import AccessError, ValidationError
from odoo import fields
@tagged("post_install", "-at_install")
class TestBatchPickingBill(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create users with different access rights
cls.warehouse_user = cls.env["res.users"].create(
{
"name": "Warehouse User",
"login": "warehouse_user",
"email": "warehouse@test.com",
"groups_id": [
(
6,
0,
[
cls.env.ref("stock.group_stock_user").id,
cls.env.ref("purchase.group_purchase_user").id,
cls.env.ref(
"account.group_account_invoice"
).id, # Basic invoice rights
],
)
],
}
)
cls.accountant = cls.env["res.users"].create(
{
"name": "Accountant",
"login": "accountant",
"email": "accountant@test.com",
"groups_id": [
(
6,
0,
[
cls.env.ref("account.group_account_invoice").id,
cls.env.ref("account.group_account_manager").id,
],
)
],
}
)
# Create vendor
cls.vendor = cls.env["res.partner"].create(
{
"name": "Test Vendor",
"email": "vendor@test.com",
"supplier_rank": 1,
}
)
# Create products
cls.product_a = cls.env["product.product"].create(
{
"name": "Product A",
"type": "consu",
"tracking": "none",
"purchase_ok": True,
}
)
cls.product_b = cls.env["product.product"].create(
{
"name": "Product B",
"type": "consu",
"tracking": "none",
"purchase_ok": True,
}
)
# Create purchase orders
po_vals = {
"partner_id": cls.vendor.id,
"order_line": [
(
0,
0,
{
"product_id": cls.product_a.id,
"name": cls.product_a.name,
"product_qty": 5.0,
"product_uom": cls.product_a.uom_po_id.id,
"price_unit": 100.0,
},
),
(
0,
0,
{
"product_id": cls.product_b.id,
"name": cls.product_b.name,
"product_qty": 3.0,
"product_uom": cls.product_b.uom_po_id.id,
"price_unit": 200.0,
},
),
],
}
cls.po1 = cls.env["purchase.order"].create(po_vals)
cls.po2 = cls.env["purchase.order"].create(po_vals)
# Confirm purchase orders and receive products
cls.po1.button_confirm()
cls.po2.button_confirm()
# Receive products in pickings
for picking in (cls.po1 + cls.po2).picking_ids:
for move in picking.move_ids:
move.quantity = move.product_qty
# Create a batch picking with the pickings
cls.batch = cls.env["stock.picking.batch"].create(
{
"name": "Test Batch",
"company_id": cls.env.company.id,
"scheduled_date": fields.Date.today(),
"picking_ids": [(6, 0, (cls.po1 + cls.po2).picking_ids.ids)],
"zero_quantity_default": False,
}
)
# Create a batch with multiple vendors for testing validation
cls.vendor2 = cls.env["res.partner"].create(
{
"name": "Second Vendor",
"email": "vendor2@test.com",
"supplier_rank": 1,
}
)
# Create a purchase order with a different vendor
po_vals_vendor2 = {
"partner_id": cls.vendor2.id,
"order_line": [
(
0,
0,
{
"product_id": cls.product_a.id,
"name": cls.product_a.name,
"product_qty": 2.0,
"product_uom": cls.product_a.uom_po_id.id,
"price_unit": 100.0,
},
),
],
}
cls.po3 = cls.env["purchase.order"].create(po_vals_vendor2)
cls.po3.button_confirm()
# Process the picking for the second vendor
for picking in cls.po3.picking_ids:
for move in picking.move_ids:
move.quantity = move.product_qty
# Create a batch with multiple vendors
cls.multi_vendor_batch = cls.env["stock.picking.batch"].create(
{
"name": "Multi Vendor Batch",
"company_id": cls.env.company.id,
"scheduled_date": fields.Date.today(),
"picking_ids": [(6, 0, (cls.po1 + cls.po3).picking_ids.ids)],
"zero_quantity_default": False,
}
)
def test_action_create_bill_success(self):
"""Test that a bill is successfully created from a batch picking"""
# Ensure the batch has no invoices initially
self.assertEqual(len(self.batch.invoice_ids), 0)
self.assertEqual(self.batch.invoice_count, 0)
# Execute the action to create a bill
action = self.batch.action_create_bill()
# Verify that an invoice was created
self.assertEqual(len(self.batch.invoice_ids), 1)
self.assertEqual(self.batch.invoice_count, 1)
# Verify the action returns the correct view
self.assertEqual(action.get("res_id"), self.batch.invoice_ids[-1].id)
self.assertEqual(action.get("views")[0][1], "form")
# Verify the bill content
bill = self.batch.invoice_ids[-1]
self.assertEqual(bill.partner_id, self.vendor)
self.assertEqual(bill.move_type, "in_invoice")
self.assertEqual(bill.invoice_date, self.batch.scheduled_date.date())
# Verify that the bill contains the correct lines
expected_products = self.batch.move_line_ids.mapped("product_id")
bill_products = bill.invoice_line_ids.mapped("product_id")
self.assertEqual(set(bill_products.ids), set(expected_products.ids))
# Verify the quantities match
for line in bill.invoice_line_ids:
# Find matching move lines
move_lines = self.batch.move_line_ids.filtered(
lambda ml: ml.product_id == line.product_id
)
self.assertEqual(line.quantity, sum(move_lines.mapped("quantity")))
# Verify price from purchase order line
purchase_line = move_lines[0].move_id.purchase_line_id
self.assertEqual(line.price_unit, purchase_line.price_unit)
def test_action_create_bill_no_purchase_orders(self):
"""Test that an error is raised when there are no purchase orders"""
# Create an empty batch
empty_batch = self.env["stock.picking.batch"].create(
{
"name": "Empty Batch",
"company_id": self.env.company.id,
"scheduled_date": fields.Date.today(),
}
)
# Try to create a bill and expect a ValidationError
with self.assertRaises(ValidationError):
empty_batch.action_create_bill()
def test_action_create_bill_multiple_vendors(self):
"""Test that an error is raised when there are multiple vendors"""
# Try to create a bill from a batch with multiple vendors
with self.assertRaises(ValidationError):
self.multi_vendor_batch.action_create_bill()
def test_action_create_bill_access_rights(self):
"""Test access rights for creating bills from batch pickings"""
# Test with warehouse user (should have access)
self.batch.with_user(self.warehouse_user).action_create_bill()
# Verify that a bill was created
self.assertEqual(len(self.batch.invoice_ids), 1)
# Create a user without invoice creation rights
no_invoice_user = self.env["res.users"].create(
{
"name": "No Invoice User",
"login": "no_invoice_user",
"email": "no_invoice@test.com",
"groups_id": [(6, 0, [self.env.ref("stock.group_stock_user").id])],
}
)
# Create a new batch for testing with the limited user
new_batch = self.env["stock.picking.batch"].create(
{
"name": "New Test Batch",
"company_id": self.env.company.id,
"scheduled_date": fields.Date.today(),
"picking_ids": [(6, 0, self.po2.picking_ids.ids)],
}
)
# Try to create a bill with a user that doesn't have invoice creation rights
with self.assertRaises(AccessError):
new_batch.with_user(no_invoice_user).action_create_bill()
def test_bill_values_calculation(self):
"""Test the helper methods that calculate bill values"""
# Test _get_bill_values method
bill_values = self.batch._get_bill_values()
self.assertEqual(bill_values["company_id"], self.batch.company_id.id)
self.assertEqual(bill_values["partner_id"], self.batch.partner_ids.id)
self.assertEqual(bill_values["move_type"], "in_invoice")
self.assertEqual(bill_values["invoice_date"], self.batch.scheduled_date)
# The invoice_origin should contain the purchase order names
for po_name in self.batch.purchase_order_ids.mapped("name"):
self.assertIn(po_name, bill_values["invoice_origin"])
# Test currency consistency
currency_id = self.batch._get_currency_id()
self.assertEqual(currency_id, self.batch.purchase_order_ids[0].currency_id.id)

View file

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_batch_form_inherit" model="ir.ui.view">
<field name="name">stock.picking.batch.form.inherit</field>
<field name="model">stock.picking.batch</field>
<field name="inherit_id" ref="stock_picking_batch.stock_picking_batch_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<field name="id" invisible="1"/>
<button name="action_create_bill" string="Create Bill" type="object" class="btn-primary" context="{'default_batch_id': id}" invisible="state != 'done'"/>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" name="action_view_invoices" type="object" icon="fa-pencil-square-o">
<field name="invoice_count" widget="statinfo" string="Factures"/>
</button>
</xpath>
<xpath expr="//group[@id='batch_delivery_data']" position="inside">
<field name="zero_quantity_default"/>
</xpath>
</field>
</record>
<!-- Inherit the move line tree view to add the Picked field -->
<record id="view_move_line_tree_inherit" model="ir.ui.view">
<field name="name">stock.move.line.tree.picked.inherit</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">
<xpath expr="//field[@name='tracking']" position="before">
<field name="picked" optional="show"/>
</xpath>
<xpath expr="//list" position="attributes">
<attribute name="decoration-success">picked</attribute>
</xpath>
</field>
</record>
</odoo>

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
from . import stock_picking_to_batch

View file

@ -1,20 +0,0 @@
from odoo import _, fields, models
from odoo.exceptions import UserError
class StockPickingToBatch(models.TransientModel):
_inherit = "stock.picking.to.batch"
zero_quantity_default = fields.Boolean(
string="Zero Quantity by Default",
default=False,
help="If checked, the default quantity for new move lines will be 0 instead of the computed quantity.",
)
def attach_pickings(self):
self.ensure_one()
# Pass zero_quantity_default through context to be handled in batch create
return super(
StockPickingToBatch,
self.with_context(default_zero_quantity_default=self.zero_quantity_default),
).attach_pickings()

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="stock_picking_to_batch_form_inherit" model="ir.ui.view">
<field name="name">stock.picking.to.batch.form.inherit</field>
<field name="model">stock.picking.to.batch</field>
<field name="inherit_id" ref="stock_picking_batch.stock_picking_to_batch_form"/>
<field name="arch" type="xml">
<field name="is_create_draft" position="after">
<field name="zero_quantity_default" invisible="mode != 'new'"/>
</field>
</field>
</record>
</odoo>

View file

@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from . import models

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
{
"name": "Change default when adding follower",
"version": "17.0.0.0.1",
"category": "Extra Tools",
'summary': 'Change default when adding follower',
"description": """
Change default when adding follower
Send mail false by default
""",
"author": "Bemade",
'website': 'https://www.bemade.org',
"depends": [
'mail',
],
"data": [
],
"auto_install": False,
"installable": True,
'license': 'OPL-1'
}

View file

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import mail_wizard_invite

View file

@ -0,0 +1,11 @@
# Copyright Bemade.org
from odoo import models, fields, api
class MailWizardInviteDefault(models.TransientModel):
_inherit = 'mail.wizard.invite'
send_mail = fields.Boolean(
default=False,
help="If true, an invitation email will be sent to the recipient"
)

View file

@ -0,0 +1,32 @@
#
# Bemade Inc.
#
# Copyright (C) September 2023 Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
# or modified copies of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
'name': 'Documents Portal Base',
'version': '17.0.1.0.0',
'summary': 'Adds documents to the front-end portal.',
'category': 'Document Management',
'author': 'Bemade Inc.',
'website': 'https://www.bemade.org',
'license': 'OPL-1',
'depends': ['documents', 'portal', 'mail_enterprise', 'im_livechat'],
'data': ['views/document_portal_templates.xml'],
'demo': [],
'installable': True,
'auto_install': False,
}

View file

@ -0,0 +1,65 @@
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.http import request, route
from odoo.exceptions import AccessError, MissingError
from odoo import _
class DocumentCustomerPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
rtn = super()._prepare_home_portal_values(counters)
domain = self._prepare_documents_domain()
rtn['documents_count'] = request.env['documents.document'].search_count(domain)
return rtn
@route('/my/documents', type='http', auth='user', website=True)
def portal_my_documents(self, **kwargs):
values = self._prepare_portal_layout_values()
Documents = request.env['documents.document']
domain = self._prepare_documents_domain()
documents_count = Documents.search_count(domain)
documents = Documents.search(domain)
values.update({
'documents_count': documents_count,
'documents': documents.sudo(),
'default_url': '/my/documents',
'page_name': 'my_documents',
})
return request.render("bemade_documents_portal.portal_my_documents", values)
def _prepare_documents_domain(self):
partner = request.env.user.partner_id
user = request.env.user
"""Helper method intended to be overridden for future modules."""
return ['|',
('partner_id', '=', partner.id),
('owner_id', '=', user.id),
]
def _render_record_template(self, values):
""" Override this method to apply a different template for a single document
record on the portal. """
return request.render("bemade_documents_portal.document_portal_template", values)
@route('/my/documents/<int:document_id>', type='http', auth='user', website=True)
def portal_document_page(self, document_id, download=False, **kwargs):
document = request.env['documents.document'].browse(document_id)
if not document:
raise MissingError(_('This document does not exist.'))
if download:
return self._download_attachment(document)
values={
'document': document,
'page_name': 'my_documents',
'action': document._get_portal_return_action(),
}
return self._render_record_template(values)
def _download_attachment(self, document):
attachment = document.attachment_id
headers = [
('content-type', attachment.mimetype),
('content-length', attachment.file_size),
('content-disposition', f'attachment; filename="{document.name}"')
]
return request.make_response(attachment.raw, headers)

View file

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

View file

@ -0,0 +1,17 @@
from odoo import models, fields
class Document(models.Model):
_name = 'documents.document'
_inherit = ['documents.document', 'portal.mixin']
def _compute_access_url(self):
super()._compute_access_url()
for document in self:
document.access_url = f'/my/documents/{document.id}'
def _get_portal_return_action(self):
""" Return the action used to display documents when returning from customer
portal."""
self.ensure_one()
return self.env.ref('documents.document_action')

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<template id="portal_my_home" inherit_id="portal.portal_my_home">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">Documents</t>
<t t-set="url">/my/documents</t>
<t t-set="placeholder_count">documents_count</t>
</t>
</xpath>
</template>
<template id="portal_my_documents" name="My Documents">
<t t-call="portal.portal_layout">
<t t-call="portal.portal_table">
<thead>
<tr class="active">
<th>Name</th>
</tr>
</thead>
<tbody>
<t t-foreach="documents" t-as="document">
<tr>
<td>
<a t-att-href="document.get_portal_url()">
<t t-esc="document.name"/>
</a>
</td>
</tr>
</t>
</tbody>
</t>
</t>
</template>
<template id="document_portal_template" name="Document Portal Template">
<t t-call="portal.portal_layout">
<t t-set="o_portal_fullwidth_alert"
groups="documents.group_documents_user">
<t t-call="portal.portal_back_in_edit_mode">
<t t-set="backend_url"
t-value="'/web#model=%s&amp;id=%s&amp;action=%s&amp;view_type=form' % (document._name, document.id, action.id)"/>
</t>
</t>
<t t-call="portal.portal_record_layout">
<t t-set="card_header">
<div class="row no-gutters">
<h5 class="mb-1 mb-md-0">
<span t-field="document.name"/>
</h5>
</div>
</t>
<t t-set="card_body">
<!-- Main Document Contents -->
<div id="document_content"
class="col-12 col-lg justify-content-end w-100 h-100">
<div t-if="'image' in document.mimetype"
class="o_attachment_preview_img">
<img id="attachment_img"
class="img img-fluid d-block"
t-attf-src="/documents/content/{{document.id}}"/>
</div>
<iframe t-if="document.mimetype == 'application/pdf'"
class="mb48 w-100 min-vh-100"
t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/documents/content/{{document.id}}&amp;filename={{document.name}}"/>
<ul class="list-group list-group-flush flex-wrap flex-row flex-lg-column">
<li class="list-group-item flex-grow-1 b-0">
<a class="btn btn-secondary btn-block o_download_btn"
t-att-href="document.get_portal_url(download=True)">
Download</a>
</li>
<li class="list-group-item flex-grow-1 b-0">
<strong class="text-muted">File Size:
<t t-call="documents.format_file_size"/>
</strong>
</li>
<li class="list-group-item flex-grow-1 b-0">
<strong class="text-muted">File Type:
<t t-esc="document.mimetype"/>
</strong>
</li>
<li class="list-group-item flex-grow-1 b-0">
<strong class="text-muted">Attachment Type:
<t t-esc="document.attachment_type"/>
</strong>
</li>
</ul>
</div>
</t>
</t>
<!-- Chatter -->
<div id="document_communication" class="card-body">
<h2>History</h2>
<t t-call="portal.message_thread">
<t t-set="object" t-value="document"/>
</t>
</div>
</t>
</template>
<template id="portal_breadcrumbs" inherit_id="portal.portal_breadcrumbs">
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
<li t-if="page_name == 'my_documents'"
t-attf-class="breadcrumb-item #{'active ' if not document else ''}">
<a t-if="document"
t-attf-href="/my/documents?{{ keep_query() }}">Documents</a>
<t t-else="">Documents</t>
</li>
<li t-if="document" class="breadcrumb-item active" t-esc="document.name">
</li>
</xpath>
</template>
</data>
</odoo>

View file

@ -0,0 +1,4 @@
# Copyright (C) 2023 Bemade.org
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import models

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
{
"name": "Fetchmail Only on production environment",
"version": "17.0.0.0.1",
"category": "Extra Tools",
'summary': 'Fetchmail Only on production environment',
"description": """
Fetchmail Only on production environment
""",
"author": "Bemade",
'website': 'https://www.bemade.org',
"depends": [
'mail',
],
"data": [
],
"auto_install": True,
"installable": True,
'license': 'OPL-1'
}

View file

@ -0,0 +1,4 @@
# Copyright (C) 2023 Bemade.org
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import fetchmail_server

View file

@ -0,0 +1,23 @@
# Copyright (C) 2023 Bemade.org
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
# Import the required classes and decorators from Odoo
from odoo import api, models
from urllib.parse import urlparse
_logger = logging.getLogger(__name__)
class fetchmail_server(models.Model):
_inherit = 'fetchmail.server'
@api.model
def fetch_mail(self):
if urlparse(self.env['ir.config_parameter'].sudo().get_param('web.base.url')).netloc == \
urlparse('https://erp.durpro.com/').netloc:
return super(fetchmail_server, self).fetch_mail()
else:
# Add log message
_logger.info("Trying to fetch email, current URL don't match with production URL, so we don't fetch email")
return True

View file

@ -0,0 +1,10 @@
15.0.0.0.1
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Initial release
16.0.0.0.1
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Modification for V16 compatibility

View file

@ -0,0 +1,33 @@
#
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
# or modified copies of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
'name': 'Fix Quality Worksheet',
'version': '17.0.1.0.0',
'summary': 'Fix Quality worksheet bug from Odoo Enterprise',
'description': '',
'category': 'Quality Control',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'OPL-1',
'depends': ['quality_control'],
'data': ['reports/worksheet_custom_report_templates.xml'],
'assets': {},
'installable': True,
'auto_install': True,
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="worksheet_page" inherit_id="quality_control.worksheet_page">
<xpath expr="//span[@t-field='doc.result']" position="replace">
<span t-field="doc.measure"/>
</xpath>
</template>
</odoo>

View file

@ -20,7 +20,7 @@
########################################################################################
{
"name": "Improved Field Service Management",
"version": "18.0.0.4.3",
"version": "17.0.0.4.1",
"summary": (
"Adds functionality necessary for managing field service operations at Durpro."
),
@ -39,6 +39,7 @@
"fsm_equipment",
"bemade_partner_root_ancestor",
"mail",
"durpro_tag_inheritance",
],
"data": [
"data/fsm_data.xml",

View file

@ -3,19 +3,26 @@
<record id="planning_project_stage_waiting_parts" model="project.task.type">
<field name="sequence">2</field>
<field name="name">Waiting on Parts</field>
<!-- BV: legend_blocked n'existe plus -->
<!-- BV: <field name="legend_blocked">Blocked</field>-->
<field name="fold" eval="False" />
<!-- <field name="is_closed" eval="False"/>-->
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
</record>
<record id="planning_project_stage_work_completed" model="project.task.type">
<field name="sequence">15</field>
<field name="name">Work Executed</field>
<!-- BV: <field name="legend_blocked">Blocked</field>-->
<field name="fold" eval="False" />
<!-- <field name="is_closed" eval="False"/>-->
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
</record>
<record id="planning_project_stage_exception" model="project.task.type">
<field name="sequence">19</field>
<field name="name">Exception</field>
<!-- BV: <field name="legend_blocked">Blocked</field>-->
<field name="fold" eval="False" />
<!-- <field name="is_closed" eval="False"/>-->
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
</record>
</odoo>

View file

@ -1068,16 +1068,18 @@ msgstr "Travaux exécutés"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block
msgid ""
"Work Order\n"
" Contacts:"
msgstr ""
"Contacts recevant le bon de travail :"
msgid "Work Order Contacts:"
msgstr "Contact recevant le bon de travail : "
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__work_order_contacts
msgid "Work Order Contacts"
msgstr "Contacts recevant le bon de travail"
#: model_terms:ir.ui.view,arch_db:bemade_fsm.work_order_page
msgid "Phone:"
msgstr "Téléphone:"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.work_order_page
msgid "Email:"
msgstr "Courriel:"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__work_order_number

Some files were not shown because too many files have changed in this diff Show more