Compare commits

...

41 commits

Author SHA1 Message Date
Marc Durepos
278540fbaf Added fr translation and implied group for inventory security 2025-10-02 17:16:54 -04:00
Marc Durepos
a080586ef4 [ADD] stock_inventory_adjustment_security: limit who can adjust stock 2025-10-02 17:03:24 -04:00
Marc Durepos
3f5c5cc038 remove migration18.md files 2025-10-02 10:38:43 -04:00
Marc Durepos
00a940672a caldav_sync fix a walrus operator/and precedence bug 2025-10-02 10:08:46 -04:00
Marc Durepos
ece737826c caldav_sync: fixed a bug in events created from appointments, added test module test_caldav_sync_appointments 2025-10-02 10:06:39 -04:00
Denis Durepos
800214b19c Fixed chatter in various bemade_addons module views 18.0. 2025-09-26 10:41:12 -04:00
Benoît Vézina
9e7662ec7e fix lot of wearming 2025-09-25 13:50:32 -04:00
xtremxpert
b779529d91 recursive_list_view 2025-09-23 15:54:53 -04:00
Denis Durepos
8f252372e4 Fixed disappeared bemade_sports_clinic menus 2025-09-22 21:40:11 -04:00
Denis Durepos
cbd9f2faab feat(portal): add timesheets portal (list + edit) and clean assets
Portal Timesheets Feature\n- Add new portal timesheets list with edit modal: views/portal_timesheets_templates.xml\n- New controller: controllers/timesheets_portal.py\n- New model for event timesheets: models/sports_event_timesheet.py\n- Security: sports_event_timesheet_rules.xml + ir.model.access.csv updates\n\nEvent/Portal Integration\n- Wire timesheets into events portal: controllers/events_portal.py, views/portal_event_* templates, menus\n- Sports event model enhancements: models/sports_event.py\n\nGeneral Portal/View Updates\n- Updates across player/team/user/partner views for consistency and UX\n\nAsset Cleanup\n- Remove unused 24h datetime initializer from frontend bundles\n  * Dropped bemade_sports_clinic/static/src/js/portal_datetime_24h.js from __manifest__.py assets\n\nNotes\n- No functional dependency on the 24h initializer remains; inputs use native datetime-local or explicit formatting in templates.
2025-09-22 21:33:57 -04:00
xtremxpert
dffb2755db port quot alt 2025-09-22 14:44:48 -04:00
Denis Durepos
3cc5889ea4 Initial import pre-upgrade for bemade_quotation_alternative:18.0 2025-09-19 13:45:28 -04:00
Denis Durepos
f6cdd94f5f Fixed alternate follower uid bug 2025-09-18 14:34:04 -04:00
Marc Durepos
21cc63df0b license for apply_inventory_prompt_for_reason 2025-09-12 06:06:17 -04:00
Marc Durepos
f830cb676f bemade_margin_vendor_pricelist: add dependency to product_pricelist_supplierinfo + formatting 2025-09-11 12:22:31 -04:00
Marc Durepos
73c74412e0 [MIG] mo_back_to_draft to 18.0 2025-09-10 14:18:26 -04:00
Marc Durepos
59fa4703ab [MIG] stock_quant_reserved_fix migrated to 18.0 2025-09-10 14:13:31 -04:00
Marc Durepos
7b8255abe2 [MIG] portal_hide_draft_order_details to 18.0 2025-09-10 14:05:01 -04:00
Marc Durepos
6accef74e2 [MIG] temporary_change_image_size to 18.0 2025-09-10 13:47:48 -04:00
Marc Durepos
2d748d7b4e [MIG] stock_valuation_location to 18.0 2025-09-10 13:46:21 -04:00
Marc Durepos
e0f55f4458 [MIG] confirm_many2one_create to 18.0 2025-09-10 13:42:57 -04:00
Marc Durepos
8e69382d84 [MIG] partner_equipment_applications to 18.0 2025-09-10 13:37:16 -04:00
Marc Durepos
4142187feb [MIG] price_update_notifications to 18.0 2025-09-10 13:33:48 -04:00
Marc Durepos
d428d673bc [MIG] improved_mo_origin to 18.0 2025-09-10 13:13:28 -04:00
Marc Durepos
1a69864176 [MIG] nuke_mid_task to 18.0 2025-09-10 10:35:23 -04:00
Marc Durepos
d69c2425ee [MIG] customer_applications to 18.0 2025-09-10 10:16:27 -04:00
Marc Durepos
e1a64337ff [MIG] aged_partner_balance_na to 18.0 2025-09-10 10:02:10 -04:00
Marc Durepos
289e8f3731 [MIG] bemade_hide_decimal_on_unit to 18.0 2025-09-10 09:47:18 -04:00
Marc Durepos
7f62a24819 [MIG] bemade_margin_vendor_pricelist to 18.0 2025-09-10 09:43:32 -04:00
Marc Durepos
2d0577d7e3 [MIG] bemade_helpdesk_one_ticket_per_email to 18.0 2025-09-10 09:32:19 -04:00
Marc Durepos
c78b926565 [MIG] bemade_partner_email_domain to 18.0 2025-09-10 09:30:02 -04:00
Marc Durepos
ba9f1e0c56 [MIG] bemade_picking_upstream to 18.0 2025-09-10 09:26:47 -04:00
Marc Durepos
5ec51c3554 [MIG] bemade_reordering_rules_chatter to 18.0 2025-09-09 15:33:33 -04:00
Marc Durepos
c19f2e29e6 [MIG] bemade_search_supplier_code to 18.0 2025-09-09 15:27:10 -04:00
Marc Durepos
8662ad3ffd [MIG] bemade_so_followers_to_picking to 18.0 2025-09-09 15:19:49 -04:00
Marc Durepos
975870333a [MIG] bemade_stock_quant_valuation to 18.0 2025-09-09 14:57:21 -04:00
Marc Durepos
ca493863cb [MIG] fsm_visit_confirmation to 18.0 2025-09-09 14:38:36 -04:00
Marc Durepos
5793351bbe g This is a combination of 2 commits.
[MIG] bemade_fsm to 18.0

Aside from fixing standard 17.0..18.0 stuff and fixing XML ID
references:

1. **Remove `_get_closed_stage_by_project()`**: This method is
obsolete - it was copied from base FSM but no longer exists in
18.0. Base system now uses `is_closed` field.
2. **Implement State Propagation**: Enhance the `write()` method
to propagate done/cancelled states to child tasks using `is_closed` field logic.
3. **Test Task Creation**: Thoroughly test the custom task creation logic
against base 18.0

All 56 tests are passing.
2025-09-09 14:05:34 -04:00
Marc Durepos
c7d0a1be2b [MIG] fsm_equipment to 18.0 2025-09-09 11:38:26 -04:00
Marc Durepos
bb8c1f2f35 [MIG] bemade_partner_root_ancestor to 18.0 2025-09-09 11:29:19 -04:00
Marc Durepos
06584b381a Migrate account_credit_hold to 18.0
Squashed commit of the following:

commit 5adce1fd15
Author: Marc Durepos <marc@bemade.org>
Date:   Thu Sep 4 12:33:00 2025 -0400

    [MIG] 17.0..18.0 account_credit_hold

    Migrated module account_credit_hold from 17.0 to 18.0. Fixed views and
    models and wrote test cases to check functionality.

commit 91c52af6f4
Author: Marc Durepos <marc@bemade.org>
Date:   Thu Sep 4 11:38:37 2025 -0400

    Initial migration of account_credit_hold for testing in 18.0
2025-09-04 12:34:47 -04:00
325 changed files with 17176 additions and 704 deletions

View file

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

View file

@ -0,0 +1,20 @@
{
"name": "Account Credit Hold",
"version": "18.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.",
"maintainer": "Marc Durepos <marc@bemade.org>",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": ["sale", "account_followup", "stock"],
"data": [
"views/account_followup_views.xml",
"views/sale_order_views.xml",
"views/res_partner_views.xml",
"views/stock_picking_views.xml",
],
"demo": [],
"installable": True,
"auto_install": False,
}

View file

@ -0,0 +1,5 @@
from . import account_followup
from . import res_partner
from . import sale_order
from . import stock_picking
from . import account_followup_report

View file

@ -0,0 +1,8 @@
from odoo import fields, models, api
class FollowupLine(models.Model):
_inherit = 'account_followup.followup.line'
account_hold = fields.Boolean(string="Place on Credit Hold",
help="Place clients on account hold, restricting confirmation of new orders.")

View file

@ -0,0 +1,16 @@
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)
res.update({
'credit_hold': partner.followup_line_id.account_hold if partner.followup_line_id else False,
'partner_on_hold': partner.on_hold
})
return res

View file

@ -0,0 +1,108 @@
from odoo import fields, models, api, _
from datetime import date
class Partner(models.Model):
_inherit = "res.partner"
postpone_hold_until = fields.Date(
string="Postpone Hold",
help="Grace period specific to this partner despite unpaid invoices.",
tracking=True,
)
hold_bg = fields.Boolean(
string="Hold (technical)",
compute="_compute_hold_bg",
store=True,
default=False,
compute_sudo=True,
tracking=True,
)
on_hold = fields.Boolean(
string="Account on Hold",
help="Client account is on hold for unpaid overdue invoices.",
compute="_compute_on_hold",
compute_sudo=True,
)
@api.depends("postpone_hold_until", "hold_bg", "commercial_partner_id.hold_bg")
def _compute_on_hold(self):
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 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:
rec.on_hold = False
@api.autovacuum
def _cleanup_expired_hold_postponements(self):
expired_holds = self.search([("postpone_hold_until", "<=", date.today())])
expired_holds.write({"postpone_hold_until": False})
def action_credit_hold(self):
for rec in self:
rec.hold_bg = True
rec.message_post(body=_("Placed on credit hold."))
def action_lift_credit_hold(self):
for rec in self:
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,
)
@api.depends("followup_status", "followup_line_id")
def _compute_hold_bg(self):
first_followup_level = self._get_first_followup_level()
for rec in self:
prev_hold_bg = rec.hold_bg
level = rec.followup_line_id
if rec.followup_status == "no_action_needed" and not level:
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

@ -0,0 +1,16 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = "sale.order"
client_on_hold = fields.Boolean(string='Client on Hold',
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.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()

View file

@ -0,0 +1,9 @@
from odoo import fields, models, api
class ModelName(models.Model):
_inherit = "stock.picking"
client_on_hold = fields.Boolean(string='Client on Hold',
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.on_hold")

View file

@ -0,0 +1,40 @@
# Overview
This module adds the notion of placing clients on credit hold to the followup levels from the Odoo Enterprise
account_followup module. It adds an option to followup levels to mark clients matching the followup criteria as on
credit hold. This hold restricts the confirmation of new sales orders for these clients.
Accountant and admin users can set a date until which the account hold will be
postponed on a specific partner's form view. This effectively gives clients an extra
grace period, allowing orders to be confirmed until the period ends.
# Change Log
## 17.0.1.0.0 (2024-05-15)
Various modifications to adapt code to Odoo 17.0
## 15.0.2.0.0 (2023-05-04)
Complete remake of the module, making the "Credit Hold" an action that is either manually or
automatically triggered from the Accounting > Followup Reports section or by setting the automatic application field
on followup levels.
## 15.0.1.1.0 (2023-05-03)
Adds a ribbon to stock pickings for clients on hold, and therefore a dependency on stock.
## 15.0.1.0.2 (2023-05-03)
Fix to sale order view and sale order confirmation for clients not on hold.
## 15.0.1.0.1 (2023-05-03)
Fix clients on hold when status is "outstanding_invoices".
## 15.0.1.0.0 (2023-05-02) Initial Release
Initial release of the module, including a setting on follow-up levels to toggle placing on credit hold. Blocks
the confirmation of sales orders for clients on credit hold. Red "Credit Hold" banner appears on sales orders and
partner form view when a client is on credit hold. Credit hold can be postponed by setting the "Postpone Hold" field
on the partner form view.

View file

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

View file

@ -0,0 +1,342 @@
# -*- 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

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="account_followup_followup_line_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.account_followup_line.form</field>
<field name="model">account_followup.followup.line</field>
<field
name="inherit_id"
ref="account_followup.view_account_followup_followup_line_form"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='send_email']" position="before">
<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>
<field
name="inherit_id"
ref="account_followup.manual_reminder_view_form"
/>
<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></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" />
<field name="binding_model_id" ref="base.model_res_partner" />
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">records.action_credit_hold()</field>
</record>
</odoo>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_partner_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.res_partner.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="hold_bg" />
<field invisible="True" name="on_hold" />
<widget
bg_color="bg-danger"
invisible="on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
<record id="view_partner_property_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.view_partner_property_form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="account.view_partner_property_form" />
<field name="arch" type="xml">
<xpath expr="//group[@name='banks']" position="before">
<group string="Credit Hold">
<field
groups="account.group_account_manager,account.group_account_user"
name="postpone_hold_until"
readonly="hold_bg == False and postpone_hold_until == False"
/>
</group>
</xpath></field>
</record>
</odoo>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_order_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.sale_order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="client_on_hold" />
<widget
bg_color="bg-danger"
invisible="client_on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
</odoo>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="stock_picking_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.stock_picking.form</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form" />
<field eval="8" name="priority" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="client_on_hold" />
<widget
bg_color="bg-danger"
invisible="client_on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
</odoo>

View file

@ -0,0 +1,33 @@
from . import models
def post_init(env):
env['account.report'].search([('name', 'ilike', 'Aged % - North America')]).unlink()
new_receivable_report = env.ref('account_reports.aged_receivable_report').copy()
new_payable_report = env.ref('account_reports.aged_payable_report').copy()
new_receivable_report.line_ids.mapped('expression_ids').write({
'formula': '_report_custom_engine_aged_receivable_na'
})
new_payable_report.line_ids.mapped('expression_ids').write({'formula': '_report_custom_engine_aged_payable_na'})
new_receivable_report.write({
'name': 'Aged Receivable - North America',
'root_report_id': env.ref('account_reports.aged_receivable_report').id,
'availability_condition': 'always',
})
new_payable_report.write({
'name': 'Aged Payable - North America',
'root_report_id': env.ref('account_reports.aged_payable_report').id,
'availability_condition': 'always',
})
col_name_mapping = [
('At Date', 'Overdue'),
('1-30', '0-29'),
('31-60', '30-59'),
('61-90', '60-89'),
('91-120', '90-119'),
('Older', 'Later'),
]
for old_name, new_name in col_name_mapping:
((new_payable_report | new_receivable_report)
.mapped('column_ids')
.filtered(lambda col: col.name == old_name)).write({'name': new_name})

View file

@ -0,0 +1,32 @@
#
# 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": "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",
}

View file

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

View file

@ -0,0 +1,251 @@
from odoo import models, fields
from odoo.tools import SQL
from itertools import chain
from dateutil.relativedelta import relativedelta
class AgedPartnerBalanceCustomHandler(models.AbstractModel):
_inherit = "account.aged.partner.balance.report.handler"
def _report_custom_engine_aged_receivable_na(
self,
expressions,
options,
date_scope,
current_groupby,
next_groupby,
offset=0,
limit=None,
warnings=None
):
return self._aged_partner_report_custom_engine_common_na(
options,
'asset_receivable',
current_groupby,
next_groupby,
offset=offset,
limit=limit
)
def _report_custom_engine_aged_payable_na(
self,
expressions,
options,
date_scope,
current_groupby,
next_groupby,
offset=0,
limit=None,
warnings=None
):
return self._aged_partner_report_custom_engine_common_na(
options,
'liability_payable',
current_groupby,
next_groupby,
offset=offset,
limit=limit
)
def _aged_partner_report_custom_engine_common_na(self, options, internal_type, current_groupby, next_groupby, offset=0, limit=None):
report = self.env['account.report'].browse(options['report_id'])
report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
def plus_days(date_obj, days):
return fields.Date.to_string(date_obj + relativedelta(days=days))
def minus_days(date_obj, days):
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
]
def build_result_dict(report, query_res_lines):
rslt = {f'period{i}': 0 for i in range(len(periods))}
for query_res in query_res_lines:
for i in range(len(periods)):
period_key = f'period{i}'
rslt[period_key] += query_res[period_key]
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
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,
'amount_currency': query_res['amount_currency'],
'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,
'total': None,
'has_sublines': query_res['aml_count'] > 0,
# Needed by the custom_unfold_all_batch_data_generator, to speed-up unfold_all
'partner_id': query_res['partner_id'][0] if query_res['partner_id'] else None,
})
else:
rslt.update({
'invoice_date': None,
'due_date': None,
'amount_currency': None,
'currency_id': None,
'currency': None,
'account_name': None,
'total': sum(rslt[f'period{i}'] for i in range(len(periods))),
'has_sublines': False,
})
return rslt
# Build period table using SQL class
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)
# 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)
always_present_groupby = SQL("period_table.period_index")
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)
else:
select_from_groupby = SQL()
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)"
)),
)
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)
SELECT
%(select_from_groupby)s
%(multiplicator)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 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 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
FROM %(table_references)s
JOIN account_journal journal ON journal.id = account_move_line.journal_id
%(currency_table_join)s
LEFT JOIN LATERAL (
SELECT
SUM(part.amount) AS amount,
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
GROUP BY part.debit_move_id
) part_debit ON TRUE
LEFT JOIN LATERAL (
SELECT
SUM(part.amount) AS amount,
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
GROUP BY part.credit_move_id
) part_credit ON TRUE
JOIN period_table ON
(
period_table.date_start IS NULL
OR COALESCE(account_move_line.date_maturity, account_move_line.date) >= DATE(period_table.date_start)
)
AND
(
period_table.date_stop IS NULL
OR COALESCE(account_move_line.date_maturity, account_move_line.date) <= DATE(period_table.date_stop)
)
WHERE %(search_condition)s
GROUP BY %(groupby_clause)s
HAVING
ROUND(SUM(%(having_debit)s), %(currency_precision)s) != 0
OR ROUND(SUM(%(having_credit)s), %(currency_precision)s) != 0
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)
query_res_lines = self._cr.dictfetchall()
if not current_groupby:
return build_result_dict(report, query_res_lines)
else:
rslt = []
all_res_per_grouping_key = {}
for query_res in query_res_lines:
grouping_key = query_res['grouping_key']
all_res_per_grouping_key.setdefault(grouping_key, []).append(query_res)
for grouping_key, query_res_lines in all_res_per_grouping_key.items():
rslt.append((grouping_key, build_result_dict(report, query_res_lines)))
return rslt

View file

@ -17,5 +17,6 @@ inventory adjustment tracking.
],
"installable": True,
"auto_install": False,
"license": "LGPL-3",
"application": False,
}

View file

@ -1,31 +0,0 @@
# Migration vers Odoo 18.0 - bemade_attachments_cleanup
## Description
Module de nettoyage des pièces jointes obsolètes
## Fonctionnalités Ajoutées
- Suppression automatique des pièces jointes non utilisées
- Configuration des règles de nettoyage
- Historique des suppressions
## Modèles et Champs Modifiés
- ir.attachment
- Ajout du champ cleanup_date (date)
- Ajout du champ cleanup_reason (text)
## Statut Migration
- [ ] A migrer
- [ ] En cours
- [ ] Migré
## Détails Migration
- Vérifier si la fonctionnalité existe déjà dans Odoo 18.0
- Analyser les impacts sur les workflows existants
## Actions Requises
- [ ] Vérifier la compatibilité avec Odoo 18.0
- [ ] Tester les fonctionnalités
- [ ] Mettre à jour la documentation
## Notes
- Ce module pourrait être remplacé par une configuration native dans Odoo 18.0

3
bemade_fsm/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from . import models
from . import reports
from . import wizard

View file

@ -0,0 +1,70 @@
######################################################################################
#
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : mdurepos@durpro.com)
#
# 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": "Improved Field Service Management",
"version": "18.0.0.4.3",
"summary": (
"Adds functionality necessary for managing field service operations at Durpro."
),
"category": "Services/Field Service",
"author": "Bemade Inc.",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": [
"sale_stock",
"sale_project",
"account",
"project_enterprise",
"industry_fsm_stock",
"industry_fsm_report",
"industry_fsm_sale_report",
"fsm_equipment",
"bemade_partner_root_ancestor",
"mail",
],
"data": [
"data/fsm_data.xml",
"views/task_template_views.xml",
"security/ir.model.access.csv",
"views/product_views.xml",
"views/res_partner.xml",
"views/menus.xml",
"views/task_views.xml",
"views/sale_order_views.xml",
"reports/worksheet_custom_report_templates.xml",
"reports/worksheet_custom_reports.xml",
"wizard/new_task_from_template.xml",
"wizard/res_config_settings.xml",
],
"assets": {
"web.report_assets_common": ["bemade_fsm/static/src/scss/bemade_fsm.scss"],
"web.assets_backend": [
# BV: need to readd these files
# 'bemade_fsm/static/src/js/kanban_view.js',
# 'bemade_fsm/static/src/js/list_view.js',
],
"web.assets_qweb": [
"bemade_fsm/static/src/xml/project_view_buttons.xml",
],
},
"installable": True,
"auto_install": False,
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="planning_project_stage_waiting_parts" model="project.task.type">
<field name="sequence">2</field>
<field name="name">Waiting on Parts</field>
<field name="fold" 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>
<field name="fold" 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>
<field name="fold" eval="False" />
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
</record>
</odoo>

1098
bemade_fsm/i18n/fr.po Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
from . import task_template
from . import product_template
from . import sale_order_line
from . import sale_order
from . import task
from . import res_partner
from . import fsm_visit
from . import res_company
from . import project

View file

@ -0,0 +1,104 @@
from odoo import models, fields, api
class FSMVisit(models.Model):
_name = "bemade_fsm.visit"
_description = "Represents a single visit by assigned service personnel."
label = fields.Text(
string="Label",
required=True,
related="so_section_id.name",
readonly=False,
copy=True,
)
approx_date = fields.Date(string="Approximate Date", copy=False)
so_section_id = fields.Many2one(
comodel_name="sale.order.line",
string="Sale Order Section",
help=(
"The section on the sale order that represents the labour and parts for"
" this visit"
),
ondelete="cascade",
)
sale_order_id = fields.Many2one(
comodel_name="sale.order",
string="Sales Order",
readonly=True,
)
is_completed = fields.Boolean(
string="Completed", related="so_section_id.is_fully_delivered"
)
is_invoiced = fields.Boolean(
string="Invoiced", related="so_section_id.is_fully_delivered_and_invoiced"
)
summarized_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Equipment to Service",
compute="_compute_summarized_equipment_ids",
)
task_id = fields.Many2one(
comodel_name="project.task",
compute="_compute_task_id",
string="Service Visit",
)
task_ids = fields.One2many(comodel_name="project.task", inverse_name="visit_id")
visit_no = fields.Integer(
compute="_compute_visit_no",
)
@api.depends("task_ids")
def _compute_task_id(self):
for rec in self:
rec.task_id = rec.task_ids and rec.task_ids[0]
@api.depends("so_section_id", "sale_order_id.summary_equipment_ids")
def _compute_summarized_equipment_ids(self):
for rec in self:
lines = rec.so_section_id.get_section_line_ids()
equipment_ids = []
for line in lines:
for equipment in line.equipment_ids:
equipment_ids.append(equipment)
rec.summarized_equipment_ids = equipment_ids
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for i, rec in enumerate(recs.filtered(lambda visit: not visit.so_section_id)):
rec.so_section_id = rec.env["sale.order.line"].create(
{
"order_id": rec.sale_order_id.id,
"display_type": "line_section",
"name": vals_list[i].get("label", False),
}
)
return recs
@api.depends(
"so_section_id",
"sale_order_id",
"sale_order_id.visit_ids",
"sale_order_id.visit_ids.so_section_id",
"sale_order_id.visit_ids.so_section_id.sequence",
)
def _compute_visit_no(self):
for rec in self:
ordered_visit_lines = self.sale_order_id.visit_ids.so_section_id.sorted(
"sequence"
)
# Just a straight O(n) search here since n will always be relatively small
for index, line in enumerate(ordered_visit_lines):
if line == rec.so_section_id:
rec.visit_no = index + 1
return

View file

@ -0,0 +1,19 @@
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
task_template_id = fields.Many2one(
comodel_name="project.task.template",
string="Task Template",
ondelete="restrict",
)
is_field_service = fields.Boolean(
string="Plan as field service",
help=(
"Products planned as field service will have travel time considered in"
" planning."
),
)

View file

@ -0,0 +1,13 @@
from odoo import models
class Project(models.Model):
_inherit = "project.project"
def _fetch_sale_order_item_ids(
self, domain_per_model=None, limit=None, offset=None
):
# Override to flush the ORM cache to the database prior to running the query
# Temporary fix until Odoo fixes this method (PR #160067 submitted for this)
self.env.flush_all()
return super()._fetch_sale_order_item_ids(domain_per_model, limit, offset)

View file

@ -0,0 +1,8 @@
from odoo import models, fields
class Company(models.Model):
_inherit = "res.company"
split_time_from_materials_on_service_work_orders = fields.Boolean(default=False)
create_default_fsm_visit = fields.Boolean(default=False)

View file

@ -0,0 +1,61 @@
from odoo import api, fields, models
class Partner(models.Model):
_inherit = "res.partner"
is_site_contact = fields.Boolean(
string="Is a site contact",
compute="_compute_is_site_contact",
search="_search_is_site_contact",
)
is_service_site = fields.Boolean(
help="A partner is a service site if they have one or more equipments, "
"site contacts or work order contacts."
)
site_ids = fields.Many2many(
string="Work Sites",
comodel_name="res.partner",
relation="res_partner_site_contact_rel",
column1="site_contact_id",
column2="site_id",
tracking=True,
domain=[("is_service_site", "=", True)],
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="res_partner_site_contact_rel",
column1="site_id",
column2="site_contact_id",
domain=[("is_service_site", "=", False)],
tracking=True,
)
work_order_contacts = fields.Many2many(
string="Work Order Recipients",
comodel_name="res.partner",
relation="res_partner_work_order_contacts_rel",
column1="res_partner_id",
column2="work_order_contact_id",
domain=[("is_service_site", "=", False)],
tracking=True,
)
@api.depends("site_ids")
def _compute_is_site_contact(self):
for rec in self:
rec.is_site_contact = rec.site_ids
@api.model
def _search_is_site_contact(self, operator, value):
return [("site_ids", "!=", False)]
@api.depends("equipment_ids", "site_contacts", "work_order_contacts")
def _compute_is_service_site(self):
for rec in self:
rec.is_service_site = (
rec.equipment_ids or rec.site_contacts or rec.work_order_contacts
)

View file

@ -0,0 +1,163 @@
from odoo import fields, models, api, _, Command
class SaleOrder(models.Model):
_inherit = "sale.order"
valid_equipment_ids = fields.One2many(
comodel_name="fsm.equipment",
related="partner_id.commercial_partner_id.owned_equipment_ids",
)
default_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Default Equipment to Service",
help="The default equipment to service for new sale order lines.",
compute="_compute_default_equipment",
inverse="_inverse_default_equipment",
store=True,
)
summary_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Equipment Being Serviced",
compute="_compute_summary_equipment_ids",
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_site_contacts_rel",
compute="_compute_default_contacts",
inverse="_inverse_default_contacts",
store=True,
)
work_order_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_work_order_contacts_rel",
compute="_compute_default_contacts",
inverse="_inverse_default_contacts",
string="Work Order Recipients",
store=True,
)
visit_ids = fields.One2many(
comodel_name="bemade_fsm.visit", inverse_name="sale_order_id", readonly=False
)
is_fsm = fields.Boolean(
compute="_compute_is_fsm",
string="Is FSM",
store=True,
)
def get_relevant_order_lines(self, task_id):
self.ensure_one()
linked_lines = self.order_line.filtered(
lambda line: line.task_id == task_id
or line == task_id.visit_id.so_section_id
)
visit_lines = linked_lines.filtered(lambda line: line.visit_id)
for line in visit_lines:
linked_lines |= line.get_section_line_ids()
return linked_lines
@api.depends("order_line.equipment_ids")
def _compute_summary_equipment_ids(self):
for rec in self:
rec.summary_equipment_ids = rec.order_line.mapped("equipment_ids")
@api.onchange("partner_shipping_id")
def _onchange_partner_shipping_id(self):
res = super()._onchange_partner_shipping_id()
self._compute_default_equipment()
self._compute_default_contacts()
return res
@api.depends("partner_shipping_id")
def _compute_default_contacts(self):
for rec in self:
rec.site_contacts = rec.partner_shipping_id.site_contacts
rec.work_order_contacts = rec.partner_shipping_id.work_order_contacts
def _inverse_default_contacts(self):
pass
@api.depends(
"partner_id",
"partner_shipping_id",
"partner_shipping_id.equipment_ids",
"partner_id.owned_equipment_ids",
)
def _compute_default_equipment(self):
for rec in self:
if rec.partner_shipping_id.equipment_ids:
ids = rec.partner_shipping_id.equipment_ids
else:
ids = rec.partner_id.owned_equipment_ids
rec.default_equipment_ids = ids if len(ids) < 4 else False
def _inverse_default_equipment(self):
pass
def copy(self, default=None):
rec = super().copy(default)
rec.visit_ids = [Command.set(rec.order_line.visit_ids.ids)]
return rec
def _create_default_visit(self):
"""Called when an order is confirmed with lines that will create an FSM task,
in order to make sure there is a visit line grouping all the service being done.
"""
self.ensure_one()
visit = self.env["bemade_fsm.visit"].create(
{
"label": _("Service Visit"),
"sale_order_id": self.id,
}
)
# Make sure it goes to the top of the list
visit.so_section_id.sequence = 0
def _create_or_organize_visits_if_needed(self):
"""Adds a visit line to the top of the order if there are not already visit
lines for an order with lines that will create an FSM task."""
for order in self.filtered("company_id.create_default_fsm_visit"):
if not order.visit_ids and order.is_fsm:
order._create_default_visit()
if order.is_fsm:
# Make sure that all the lines producing FSM tasks are under a visit
visit_line_ids = (
order.mapped("visit_ids")
.mapped("so_section_id")
.mapped("section_line_ids")
)
if any(
[
True
for line in order.order_line.filtered(
lambda line: not line.display_type
)
if line not in visit_line_ids
]
):
# If not, promote the first visit to the top of the order items list
for line in order.order_line:
line.sequence += 1
order.mapped("visit_ids").mapped("so_section_id")[0].sequence = 0
@api.depends("order_line.is_fsm")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = any([line.is_fsm for line in rec.order_line])
def action_confirm(self):
self._create_or_organize_visits_if_needed()
return super().action_confirm()
def write(self, vals):
res = super().write(vals)
if "partner_shipping_id" in vals:
for rec in self:
rec.tasks_ids.write({"partner_id": rec.partner_shipping_id.id})
return res

View file

@ -0,0 +1,304 @@
from odoo import fields, models, api, _, Command
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
valid_equipment_ids = fields.One2many(
comodel_name="fsm.equipment", related="order_id.valid_equipment_ids"
)
visit_ids = fields.One2many(
comodel_name="bemade_fsm.visit",
inverse_name="so_section_id",
string="Visits",
copy=True,
)
visit_id = fields.Many2one(
comodel_name="bemade_fsm.visit",
compute="_compute_visit_id",
string="Visit",
ondelete="cascade",
store=True,
)
is_fully_delivered = fields.Boolean(
string="Fully Delivered",
compute="_compute_is_fully_delivered",
help=(
"Indicates whether a line or all the lines in a section have been entirely"
" delivered."
),
)
is_fully_delivered_and_invoiced = fields.Boolean(
string="Fully Invoiced",
compute="_compute_is_fully_invoiced",
help=(
"Indicates whether a line or all the lines in a section have been entirely"
" delivered and invoiced."
),
)
equipment_ids = fields.Many2many(
string="Equipment to Service",
comodel_name="fsm.equipment",
relation="bemade_fsm_equipment_sale_order_line_rel",
column1="sale_order_line_id",
column2="equipment_id",
)
is_field_service = fields.Boolean(compute="_compute_is_field_service", store=True)
is_fsm = fields.Boolean(
string="Is FSM",
compute="_compute_is_fsm",
store=True,
)
section_line_ids = fields.One2many(
comodel_name="sale.order.line",
compute="_compute_section_line_ids",
)
@api.depends("visit_ids")
def _compute_visit_id(self):
for rec in self:
rec.visit_id = rec.visit_ids and rec.visit_ids[0]
@api.depends("product_id")
def _compute_is_field_service(self):
for rec in self:
rec.is_field_service = rec.product_id.is_field_service
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for rec in recs:
if rec.order_id.default_equipment_ids and not rec.equipment_ids:
rec.equipment_ids = rec.order_id.default_equipment_ids
return recs
def copy_data(self, default=None):
if default is None:
default = {}
if "visit_ids" not in default:
default["visit_ids"] = [
(0, 0, visit.copy_data()[0]) for visit in self.visit_ids
]
return super().copy_data(default)
def _timesheet_create_task(self, project):
"""Generate task for the given so line, and link it.
:param project: record of project.project in which the task should be created
:return task: record of the created task
Override to add the logic needed to implement task templates and equipment linkages.
"""
def _create_task_from_template(project, template, parent):
"""Recursively generates the task and any subtasks from a project.task.template.
:param project: project.project record to set on the task's project_id field.
:param template: project.task.template to use to create the task.
:param parent: project.task to set as the parent to this task.
"""
values = _timesheet_create_task_prepare_values_from_template(
project, template, parent
)
task = self.env["project.task"].sudo().create(values)
subtasks = []
for t in template.subtasks:
subtask = _create_task_from_template(project, t, task)
subtasks.append(subtask)
# We don't want to see the sub-tasks on the SO
if task.child_ids:
task.child_ids.write(
{
"sale_order_id": None,
"sale_line_id": None,
}
)
return task
def _timesheet_create_task_prepare_values_from_template(
project, template, parent
):
"""Copies the values from a project.task.template over to the set of values used to create a project.task.
:param project: project.project record to set on the task's project_id field.
Pass the project.project model or an empty recordset to leave task project_id blank.
DO NOT pass False or None as this will cause an error in _timesheet_create_task_prepare_values(project).
:param template: project.task.template to use to create the task.
:param parent: project.task to set as the parent to this task.
"""
vals = self._timesheet_create_task_prepare_values(project)
vals["name"] = template.name
vals["description"] = (
template.description or "" if parent else vals["description"]
)
vals["parent_id"] = parent and parent.id
vals["user_ids"] = template.assignees.ids
vals["tag_ids"] = template.tags.ids
vals["allocated_hours"] = template.planned_hours
vals["sequence"] = template.sequence
# Use shipping address for FSM tasks for consistency
if project and project.is_fsm:
vals["partner_id"] = self.order_id.partner_shipping_id.id
else:
vals["partner_id"] = self.order_id.partner_id.id
if template.equipment_ids:
vals["equipment_ids"] = template.equipment_ids.ids
return vals
tmpl = self.product_id.task_template_id
if not tmpl:
task = super()._timesheet_create_task(project)
# For FSM tasks without a template, update partner_id to use shipping address
if project.is_fsm and task:
task.partner_id = self.order_id.partner_shipping_id.id
else:
task = _create_task_from_template(project, tmpl, None)
self.write({"task_id": task.id})
# post message on task
task_msg = _(
"This task has been created from: <a href=# data-oe-model=sale.order"
" data-oe-id=%(so_id)d>%(so_name)s</a> (%(product_name)s)"
) % {
"so_id": self.order_id.id,
"so_name": self.order_id.name,
"product_name": self.product_id.name,
}
task.message_post(body=task_msg)
if not task.equipment_ids and self.equipment_ids:
task.equipment_ids = self.equipment_ids.ids
return task
def _timesheet_service_generation(self):
super()._timesheet_service_generation()
visit_lines = self.filtered(lambda line: line.visit_id)
for index, line in enumerate(visit_lines):
task_ids = line.get_section_line_ids().mapped("task_id")
if not task_ids:
continue
if len(set([task.project_id for task in task_ids])) > 1:
# Can't group up the tasks if they're part of different projects
return
project_id = task_ids[0].project_id
line.visit_id.task_id = line._generate_task_for_visit_line(
project_id, index + 1, sum(task_ids.mapped("allocated_hours"))
)
task_ids.write({"parent_id": line.visit_id.task_id.id})
(self.mapped("task_id") | self.visit_ids.task_id).filtered(
"is_fsm"
).synchronize_name_fsm()
def _generate_task_for_visit_line(
self, project, visit_no: int, allocated_hours: int
):
self.ensure_one()
allocated_hours = sum(
self.get_section_line_ids().task_id.mapped("allocated_hours")
)
task = self.env["project.task"].create(
{
"name": (
f"{self.order_id.name} - "
+ _("Visit")
+ f" {visit_no} - {self.name}"
),
"project_id": project.id,
"equipment_ids": (
self.get_section_line_ids().mapped("equipment_ids").ids
),
"sale_order_id": self.order_id.id,
"partner_id": self.order_id.partner_shipping_id.id,
"visit_id": self.visit_id.id,
"allocated_hours": allocated_hours,
"date_deadline": self.visit_id.approx_date,
"user_ids": False, # Force to empty or it uses the current user
}
)
return task
@api.depends(
"order_id.order_line",
"display_type",
"qty_to_deliver",
"order_id.order_line.qty_to_deliver",
"order_id.order_line.display_type",
)
def _compute_is_fully_delivered(self):
for rec in self:
rec.is_fully_delivered = rec._iterate_items_compute_bool(
lambda line: line.qty_to_deliver == 0
)
@api.depends("is_fully_delivered")
def _compute_is_fully_invoiced(self):
for rec in self:
if not rec.is_fully_delivered:
rec.is_fully_delivered_and_invoiced = False
return
rec.is_fully_delivered_and_invoiced = rec._iterate_items_compute_bool(
lambda line: line.qty_to_invoice == 0
)
def get_section_line_ids(self):
"""Return a recordset of sale.order.line records that are in this sale order section."""
self.ensure_one()
assert (
self.display_type == "line_section"
), "Cannot get section lines for a non-section."
found = False
lines = []
for line in self.order_id.order_line.sorted(lambda line: line.sequence):
if line == self:
found = True
continue
if not found:
continue
if line.display_type == "line_section": # Stop when we hit the next section
break
else:
lines.append(line)
return self.env["sale.order.line"].union(*lines)
@api.depends("display_type", "order_id.order_line")
def _compute_section_line_ids(self):
for rec in self:
if rec.display_type == "line_section":
rec.section_line_ids = [Command.set(rec.get_section_line_ids().ids)]
else:
rec.section_line_ids = False
def _iterate_items_compute_bool(self, single_line_func):
if not self.display_type:
return single_line_func(self)
elif self.display_type == "line_note":
return True
else:
for line in self.order_id.order_line:
found = False
if line == self:
found = True
if not found:
continue
if found and line.display_type == "line_section":
return True
val = single_line_func(self)
if not val:
return val
return True
@api.depends("product_id.type", "product_id.service_tracking")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = (
rec.product_id.type == "service"
and rec.product_id.service_tracking == "task_global_project"
)

257
bemade_fsm/models/task.py Normal file
View file

@ -0,0 +1,257 @@
from odoo import fields, models, api, Command
from odoo.addons.project.models.project_task import CLOSED_STATES
import re
from typing import cast, List
class Task(models.Model):
_inherit = "project.task"
work_order_contacts = fields.Many2many(
comodel_name="res.partner",
relation="task_work_order_contact_rel",
column1="task_id",
column2="res_partner_id",
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="task_site_contact_rel",
column1="task_id",
column2="res_partner_id",
)
# Override related field to make it return false if this is an FSM subtask
allow_billable = fields.Boolean(
string="Can be billed",
related=False,
compute="_compute_allow_billable",
store=True,
)
visit_id = fields.Many2one(
comodel_name="bemade_fsm.visit",
string="Visit",
)
relevant_order_lines = fields.Many2many(
comodel_name="sale.order.line",
store=False,
compute="_compute_relevant_order_lines",
)
work_order_number = fields.Char(readonly=True)
propagate_assignment = fields.Boolean(
help="Propagate assignment of this task to all subtasks.",
default=False,
)
is_closed = fields.Boolean(
compute="_compute_is_closed",
)
root_ancestor = fields.Many2one(
comodel_name="project.task",
compute="_compute_root_ancestor",
recursive=True,
)
def _compute_is_closed(self):
for rec in self:
rec.is_closed = rec.state in CLOSED_STATES
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
for rec in res:
if rec.parent_id and rec.is_fsm:
# Always ensure FSM subtasks have a partner_id set from their parent
rec.partner_id = rec.parent_id.partner_id
if not rec.work_order_contacts and rec.parent_id:
rec.work_order_contacts = rec.parent_id.work_order_contacts
if not rec.site_contacts and rec.parent_id:
rec.site_contacts = rec.parent_id.site_contacts
if rec.sale_order_id:
seq = 1
prev_seqs = (
self.sale_order_id.tasks_ids
and self.sale_order_id.tasks_ids.mapped("work_order_number")
)
if prev_seqs:
pattern = re.compile(r"(\d+)$")
matches = map(
lambda n: pattern.search(n), cast(List[str], prev_seqs)
)
seq += max(map(lambda n: int(n.group(1)) if n else 0, matches))
rec.work_order_number = (
rec.sale_order_id.name.replace("SO", "SVR", 1) + f"-{seq}"
)
# If the task is linked to a sales order and has no parent, it should inherit SO work order contacts
if (
not rec.parent_id
and not rec.work_order_contacts
and rec.sale_order_id.work_order_contacts
):
rec.work_order_contacts = rec.sale_order_id.work_order_contacts
if (
not rec.parent_id
and not rec.site_contacts
and rec.sale_order_id.site_contacts
):
rec.site_contacts = rec.sale_order_id.site_contacts
return res
def write(self, vals):
res = super().write(vals)
if not self: # End recursion on empty RecordSet
return res
if "propagate_assignment" in vals:
# When a user sets propagate assignment, it should propagate that setting all the way down the chain
self.child_ids.write({"propagate_assignment": vals["propagate_assignment"]})
if "user_ids" in vals:
to_propagate = self.filtered(lambda task: task.propagate_assignment)
# Here we use child_ids instead of _get_all_subtasks() so as to allow for setting propagate_assignment
# to false on a child task.
to_propagate.child_ids.write({"user_ids": vals["user_ids"]})
for rec in self:
if rec.child_ids:
child_vals = {}
if "site_contacts" in vals:
child_vals.update(
site_contacts=[Command.set(rec.site_contacts.ids)]
)
if "work_order_contacts" in vals:
child_vals.update(
work_order_contacts=[Command.set(rec.work_order_contacts.ids)]
)
if "partner_id" in vals:
child_vals.update(partner_id=vals["partner_id"])
if "state" in vals and rec.state in CLOSED_STATES:
# Propagate task completion or cancelling to subtasks
child_vals.update(state=rec.state)
if child_vals:
rec.child_ids.write(child_vals)
return res
@api.depends("sale_order_id")
def _compute_relevant_order_lines(self):
for rec in self:
rec.relevant_order_lines = (
rec.sale_order_id
and rec.sale_order_id.get_relevant_order_lines(rec)
or False
)
@api.depends("parent_id.visit_id", "project_id.is_fsm", "project_id.allow_billable")
def _compute_allow_billable(self):
for rec in self:
# If an FSM task has a parent that is linked to an SO line, then the parent is the billable one
if (
rec.parent_id
and not rec.parent_id.visit_id
and rec.project_id
and rec.project_id.is_fsm
):
rec.allow_billable = False
else:
rec.allow_billable = rec.project_id.allow_billable
def _fsm_create_sale_order_line(self):
# Override to not generate new lines for tasks that are just linked to a visit item
self.ensure_one()
if self.visit_id:
return
else:
super()._fsm_create_sale_order_line()
def action_fsm_validate(self, stop_running_timers=False):
# Override to close out subtasks as well
all_tasks = self | self._get_all_subtasks()
return super(Task, all_tasks).action_fsm_validate(stop_running_timers)
def _get_full_hierarchy(self):
if self.child_ids:
return self | self.child_ids._get_full_hierarchy()
return self
def synchronize_name_fsm(self):
"""Applies naming to the entire task tree for tasks that are part of this
recordset. Root tasks are named:
Partner Shipping Name - Sale Line Name (Template Name)
Child tasks with sale_line_id are named by their template if set, sale line name
if not.
Child tasks not linked to sale lines are left with their original names."""
all_tasks = self | self._get_all_subtasks()
for rec in all_tasks:
assert rec.is_fsm, "This method should only be called on FSM tasks."
template = rec.sale_line_id and rec.sale_line_id.product_id.task_template_id
if template:
title = template.name
elif rec.sale_line_id:
name_parts = rec.sale_line_id.name.split("\n")
title = name_parts and name_parts[0] or rec.sale_line_id.product_id.name
elif rec.visit_id:
title = rec.visit_id.label
else:
rec.name = rec.name
return
client_name = rec.sale_order_id.partner_shipping_id.name
if not rec.parent_id:
rec.name = f"{rec.work_order_number} - {client_name} - {title}"
else:
rec.name = title
@api.depends("parent_id", "parent_id.root_ancestor")
def _compute_root_ancestor(self):
for rec in self:
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or self
@api.depends(
"partner_id",
"sale_line_id.order_partner_id",
"parent_id.sale_line_id",
"project_id.sale_line_id",
"milestone_id.sale_line_id",
"allow_billable",
)
def _compute_sale_line(self):
"""Override to prevent subtasks from inheriting parent's sale_line_id.
In the base implementation, if a task and its parent share the same commercial partner,
the task will inherit the parent's sale_line_id. This causes issues with our FSM tasks
where we explicitly want subtasks to NOT have a sale_line_id set.
"""
# Only run on root tasks
subtasks = self.filtered("parent_id")
(subtasks - subtasks.filtered("sale_line_id")).sale_line_id = False
super(Task, self - subtasks)._compute_sale_line()
@api.depends("parent_id.partner_id", "project_id")
def _compute_partner_id(self):
"""Override to prevent clearing partner_id for FSM tasks.
In the base implementation, if a task has a partner_id but no project_id or parent_id,
the partner_id is cleared. This causes issues with our FSM tasks where we want to
preserve the partner_id even if project_id or parent_id is temporarily not set.
"""
# Only run the standard logic on non-FSM tasks
non_fsm_tasks = self.filtered(lambda t: not t.is_fsm)
super(Task, non_fsm_tasks)._compute_partner_id()
# For FSM tasks, only set partner_id if it's not already set
fsm_tasks = self - non_fsm_tasks
for task in fsm_tasks:
if not task.partner_id:
task.partner_id = self._get_default_partner_id(
task.project_id, task.parent_id
)

View file

@ -0,0 +1,122 @@
from odoo import models, fields, api, Command
class TaskTemplate(models.Model):
_name = "project.task.template"
_description = "Template for new project tasks"
@api.model
def _current_company(self):
return self.env.company
name = fields.Char(string="Task Title", required=True)
description = fields.Html()
assignees = fields.Many2many(
comodel_name="res.users",
string="Default Assignees",
help="Employees assigned to tasks created from this template.",
)
customer = fields.Many2one(
comodel_name="res.partner",
string="Default Customer",
help="Default customer for tasks created from this template.",
default=lambda self: self.parent.customer if self.parent else False,
)
project = fields.Many2one(
comodel_name="project.project",
string="Default Project",
help="Default project for tasks created from this template.",
)
tags = fields.Many2many(
comodel_name="project.tags",
string="Default Tags",
help="Default tags for tasks created from this template.",
)
parent = fields.Many2one(
comodel_name="project.task.template",
string="Parent Task Template",
ondelete="cascade",
)
subtasks = fields.One2many(
comodel_name="project.task.template",
inverse_name="parent",
string="Subtask Templates",
)
sequence = fields.Integer()
company_id = fields.Many2one(
comodel_name="res.company", string="Company", index=1, default=_current_company
)
planned_hours = fields.Float("Initially Planned Hours")
equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
relation="bemade_fsm_task_template_equipment_rel",
column1="task_template_id",
column2="equipment_id",
string="Equipment to Service",
)
def action_open_task(self):
return {
"view_mode": "form",
"res_model": "project.task.template",
"res_id": self.id,
"type": "ir.actions.act_window",
"context": self._context,
}
@api.onchange("customer")
def _onchange_customer(self):
for rec in self:
new_equipment_ids = [
eq.id for eq in rec.equipment_ids if eq.partner_id == rec.customer
]
rec.write({"equipment_ids": [Command.set(new_equipment_ids)]})
def _prepare_new_task_values_from_self(self, project, name=False, parent_id=False):
partner_id = self.customer or self.parent.customer or False
if not partner_id and parent_id:
parent = self.env["project.task"].browse(parent_id)
partner_id = parent.partner_id or parent.sale_order_id.partner_shipping_id
vals = {
"project_id": project.id,
"name": name or self.name,
"description": self.description,
"parent_id": parent_id,
"user_ids": self.assignees.ids,
"tag_ids": self.tags.ids,
"allocated_hours": self.planned_hours,
"sequence": self.sequence,
"equipment_ids": (
[Command.set(self.equipment_ids.ids)] if self.equipment_ids else False
),
"partner_id": partner_id and partner_id.id,
"company_id": self.company_id.id,
}
return vals
def create_task_from_self(self, project, name=False, parent_id=False):
"""Create a project.task from this template and return it.
Can be called on a RecordSet of multiple templates.
:param project: project.project record the task should be added to
:param name: name for the new task (defaults to template name)
:param parent_id: parent task for the new task (none by default)
:return: project.task record created from this template
"""
tasks = self.env["project.task"]
for rec in self:
vals = rec._prepare_new_task_values_from_self(project, name, parent_id)
task = rec.env["project.task"].create(vals)
rec.subtasks.create_task_from_self(project, name=False, parent_id=task.id)
tasks |= task
return tasks

View file

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

View file

@ -0,0 +1,581 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="workorder_page_materials_table">
<t
t-set="order_lines"
t-value="doc.relevant_order_lines.filtered(lambda l:
l.product_id.type != 'service' and not l.is_downpayment and not l.visit_id)"
/>
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')" />
<t
t-set="section_lines"
t-value="order_lines.filtered(lambda l: l.display_type == 'line_section')"
/>
</t>
<div
t-if="order_lines.filtered(lambda l: not l.display_type)"
style="page-break-inside: avoid;"
>
<h2 t-if="order_lines">Materials</h2>
<div t-if="order_lines" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th class="text-left">Description</th>
<th class="text-right">Ordered</th>
<th class="text-right">Delivered</th>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-foreach="order_lines" t-as="line">
<t t-set="is_task" t-value="line == doc.sale_line_id" />
<tr
t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''"
>
<t
t-if="not line.display_type and not line.is_downpayment"
>
<td><span t-field="line.name" /></td>
<td class="text-right">
<span t-field="line.product_uom_qty" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
<td class="text-right">
<span t-field="line.qty_delivered" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
</t>
<t t-if="line.display_type == 'line_section'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_sale_order_table_with_pricing_and_labour">
<t t-set="order_lines" t-value="doc.relevant_order_lines" />
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')" />
<t
t-if="visit_lines"
t-set="root_tasks"
t-value="visit_lines.mapped('task_id')"
/>
<t
t-else=""
t-set="root_tasks"
t-value="order_lines.mapped('task_id').filtered(lambda l: not l.parent_id)"
/>
<t t-set="final_subtotal" t-value="0" />
<t t-set="final_tax" t-value="0" />
<t t-set="final_total" t-value="0" />
<t t-set="is_any_total_discount_line" t-value="False" />
<t
t-set="section_lines"
t-value="order_lines.filtered(lambda l: l.display_type == 'line_section')"
/>
<t t-foreach="order_lines" t-as="line">
<t
t-set="final_subtotal"
t-value="final_subtotal + line.delivered_price_subtotal"
/>
<t
t-set="final_total"
t-value="final_total + line.delivered_price_total"
/>
<t t-set="final_tax" t-value="final_tax + line.delivered_price_tax" />
<t
t-set="is_any_total_discount_line"
t-value="is_any_total_discount_line or (line.discount and line.price_unit != 0 and line.delivered_price_total == 0)"
/>
</t>
<t
t-set="display_discount"
t-value="any(line.discount for line in order_lines)"
/>
</t>
<t t-else="" t-set="root_tasks" t-value="doc.root_ancestor" />
<div t-if="order_lines.filtered(lambda l: not l.display_type)">
<h2 t-if="order_lines">Time &amp; Material</h2>
<div t-if="order_lines" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th class="text-left">Description</th>
<th class="text-right">Ordered</th>
<th class="text-right">Delivered</th>
<th class="text-right">Unit Price</th>
<th
t-if="display_discount"
class="text-right"
groups="sale.group_discount_per_so_line"
>
<span>Disc.%</span>
</th>
<th class="text-right">
<span>
Amount</span>
<span>
Total Price</span>
</th>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-set="current_subtotal" t-value="0" />
<t t-foreach="order_lines" t-as="line">
<t t-set="is_task" t-value="line == doc.sale_line_id" />
<t
t-set="is_total_discount"
t-value="line.discount and line.price_unit != 0 and line.delivered_price_total == 0"
/>
<t
t-set="current_subtotal"
t-value="current_subtotal + line.delivered_price_subtotal"
/>
<t
t-set="current_total"
t-value="current_subtotal + line.delivered_price_total"
/>
<tr
t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''"
>
<t
t-if="not line.display_type and not line.is_downpayment"
>
<td><span t-field="line.name" /></td>
<td class="text-right">
<span t-field="line.product_uom_qty" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
<td class="text-right">
<span t-field="line.qty_delivered" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
<td class="text-right">
<span t-field="line.price_unit" />
</td>
<td
t-if="display_discount"
class="text-right"
groups="sale.group_discount_per_so_line"
>
<span t-field="line.discount" />
</td>
<td class="text-right o_price_total">
<span t-field="line.delivered_price_subtotal" />
</td>
</t>
<t t-if="line.display_type == 'line_section'">
<td colspan="99">
<span t-field="line.name" />
</td>
<t t-set="current_section" t-value="line" />
<t t-set="current_subtotal" t-value="0" />
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
</tr>
<t
t-if="current_section and len(section_lines) > 1 and (line_last or order_lines[line_index+1].display_type == 'line_section')"
>
<tr class="is-subtotal text-right">
<td colspan="99">
<strong class="mr16">Section
Subtotal</strong>
<span
t-esc="current_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
</div>
<div class="container_subtotal" name="so_total_summary">
<div id="total" class="row justify-content-end" name="total">
<div
t-attf-class="#{'col-auto' if report_type != 'html' else 'col-sm-2'}"
>
<table class="table table-sm">
<tr
t-if="final_tax"
class="border-black o_subtotal"
style=""
>
<td><strong>Untaxed amount</strong></td>
<td class="text-right">
<span
t-esc="final_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
<tr t-if="final_tax" class="border-black o_subtotal">
<td><strong>Taxes</strong></td>
<td class="text-right">
<span
t-esc="final_tax"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
<tr t-if="final_total" class="border-black o_total">
<td><strong>Total</strong></td>
<td class="text-right">
<span
t-esc="final_total"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
<template id="subtask_list">
<ul t-if="task.child_ids" class="ml-1 pl-1" style="list-style-type: none;">
<t t-foreach="task.child_ids" t-as="subtask">
<li class="ml-1">
<span t-out="subtask.name" />
<t t-call="bemade_fsm.subtask_list">
<t t-set="task" t-value="subtask" />
</t>
</li>
</t>
</ul>
</template>
<template id="workorder_page_tasks_table">
<t t-set="interventions" t-value="doc.child_ids" />
<t t-foreach="interventions" t-as="intervention">
<div style="page-break-inside: avoid;">
<h2 t-out="str(intervention_index + 1) + '. ' + intervention.name" />
<h3 t-if="intervention.equipment_ids">Equipment:
<t t-foreach="intervention.equipment_ids" t-as="equipment_id">
<span
t-out="equipment_id.display_name + (', ' if not equipment_id_last else '')"
/>
</t>
</h3>
<div>
<span t-esc="intervention.description" />
</div>
<t t-set="tasks" t-value="intervention.child_ids" />
<div t-if="tasks" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th width="7%">Status</th>
<th width="39%">Task</th>
<th width="54%">Comments</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr class="p-1 m-1">
<td t-out="task.stage_id.name" />
<td>
<span t-out="task.name" />
</td>
<td t-out="task.description or ''" />
</tr>
<tr t-if="task.child_ids" class="mt-0 pt-0">
<td />
<td>
<t t-call="bemade_fsm.subtask_list" />
</td>
<td />
</tr>
</t>
</tbody>
</table>
</div>
</div>
</t>
</template>
<template id="workorder_page_info_block">
<h1>
<span name="work_order_number" t-out="doc.name" />
</h1>
<h4 t-if="doc.sale_order_id and doc.sale_order_id.client_order_ref">
Purchase Order: <span
name="po_number"
t-out="doc.sale_order_id.client_order_ref"
/>
</h4>
<hr />
<div class="row" name="address_and_time">
<div
t-attf-class="{{('col-6' if report_type == 'pdf' else 'col-md-6 col-12') + ' mb-3'}}"
>
<t t-if="doc.partner_id">
<div><h6>Customer: </h6></div>
<div
t-esc="doc.partner_id"
t-options='{
"widget": "contact",
"fields": ["name", "address",],
"lang": "fr_FR"
}'
/>
</t>
</div>
<div
t-attf-class="{{('col-6' if report_type == 'pdf' else 'col-md-6 col-12') + ' mb-3'}}"
t-if="doc.planned_date_begin or doc.date_deadline"
>
<div t-if="doc.planned_date_begin"><h6>Planned start: </h6></div>
<div class="mb-3">
<div t-esc="context_timestamp(doc.planned_date_begin).strftime('%Y-%m-%d %H:%M')" />
</div>
<div t-if="doc.date_deadline"><h6>Planned end: </h6></div>
<div class="mb-3">
<div t-out="context_timestamp(doc.date_deadline).strftime('%Y-%m-%d %H:%M')" />
</div>
</div>
</div>
<div class="row" name="site_and_work_order_contacts">
<div
t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}"
>
<div t-if="doc.site_contacts"><h6>Site Contacts: </h6></div>
<t t-foreach="doc.site_contacts" t-as="contact">
<div class="mb-3">
<div
t-esc="contact"
t-options='{
"widget": "contact",
"fields": ["name", "phone", "email"]
}'
/>
</div>
</t>
</div>
<div
t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}"
>
<div t-if="doc.work_order_contacts"><h6>Work Order
Contacts: </h6></div>
<t t-foreach="doc.work_order_contacts" t-as="contact">
<div class="mb-3">
<div
t-esc="contact"
t-options='{
"widget": "contact",
"fields": ["name", "phone", "email"]
}'
/>
</div>
</t>
</div>
</div>
<hr />
<div
t-if="not is_html_empty(doc.description)"
class="row"
name="visit_description"
>
<div class="col-12">
<span t-out="doc.description" />
</div>
</div>
<hr t-if="not is_html_empty(doc.description)" />
</template>
<template id="workorder_equipment_summary">
<div
t-if="doc.equipment_ids and len(doc.child_ids) > 1"
style="page-break-inside: avoid;"
>
<h3>Equipment Serviced</h3>
<div class="table-responsive-sm">
<table class="table table-sm o_main_table overflow-hidden">
<thead>
<tr>
<th>Tag</th>
<th>Name</th>
<th>Application</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.equipment_ids" t-as="equipment">
<tr>
<td><span t-esc="equipment.code" /></td>
<td><span t-esc="equipment.name" /></td>
<td>
<t t-foreach="equipment.tag_ids" t-as="application">
<span
t-esc="application.name + (', ' if not application_last else '')"
/>
</t>
</td>
<td
t-esc="equipment.partner_id"
t-options='{
"widget": "contact",
"fields": ["name", "address"]}'
/>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_timesheet_entries">
<t t-set="timesheets" t-value="doc.timesheet_ids" />
<div t-if="timesheets" style="page-break-inside: avoid;">
<h1>Time</h1>
<div class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th width="14%">Date</th>
<th width="30%">Technician</th>
<th width="49%">Work Completed</th>
<th width="7%" class="text-right">Time</th>
</tr>
</thead>
<tbody>
<tr t-foreach="timesheets" t-as="timesheet" class="p-1 m-1">
<td>
<span t-out="timesheet.date.strftime('%Y-%m-%d')" />
</td>
<td>
<span t-field="timesheet.employee_id.name" />
</td>
<td>
<span t-field="timesheet.name" />
</td>
<td>
<span
t-esc="timesheet.unit_amount"
t-options="{
'widget': 'duration',
'digital': True,
'unit': 'hour',
'round': 'minute'
}"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_signature_block">
<div t-if="doc.worksheet_signature">
<div
t-if="report_type == html"
class="ribbon"
style="
position: absolute;
right: 0px; top: 0px;
z-index: 1;
overflow: hidden;
width: 75px; height: 75px;
text-align: right;"
>
<span
style="
font-size: 10px;
color: #fff;
text-transform: uppercase;
text-align: center;
font-weight: bold; line-height: 20px;
transform: rotate(45deg);
width: 100px; height: auto; display: block;
background: green;
position: absolute;
top: 19px; right: -21px; left: auto;
padding: 0;"
>
Signed
</span>
</div>
<div
t-attf-class="#{' col-12 col-lg-3' if report_type != 'html' else '
col-sm-7 col-md-4'} ml-auto text-right"
style="page-break-inside: avoid"
>
<h5>Signature</h5>
<img
t-att-src="image_data_uri(doc.worksheet_signature)"
style="max-height: 6rem; max-width: 100%; color:black;"
/><br />
<span t-field="doc.worksheet_signed_by" />
</div>
</div>
</template>
<template id="work_order_page">
<div class="page">
<t t-call="bemade_fsm.workorder_page_info_block" />
<t t-call="bemade_fsm.workorder_page_timesheet_entries" />
<t t-if="split_time_materials">
<t t-call="bemade_fsm.workorder_page_materials_table" />
</t>
<t t-else="">
<t
t-call="bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour"
/>
</t>
<t t-call="bemade_fsm.workorder_equipment_summary" />
<t t-call="bemade_fsm.workorder_page_signature_block" />
<t t-call="bemade_fsm.workorder_page_tasks_table" />
</div>
</template>
<template id="work_order">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="doc" t-value="doc.root_ancestor.with_context(tz=doc.partner_id.tz)" t-if="doc.parent_id"/>
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
<t t-call="web.external_layout">
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
</t>
</t>
</t>
</template>
<template
id="worksheet_custom"
inherit_id="industry_fsm.worksheet_custom"
priority="100"
>
<xpath expr="//t[@t-call='web.external_layout']" position="replace">
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
<t t-call="web.external_layout" t-lang="lang">
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
</t>
</xpath>
</template>
</odoo>

View file

@ -0,0 +1,13 @@
from odoo import models
class TaskCustomReport(models.AbstractModel):
_inherit = "report.industry_fsm.worksheet_custom"
def _get_report_values(self, docids, data=None):
vals = super()._get_report_values(docids, data)
split_time_materials = (
self.env.company.split_time_from_materials_on_service_work_orders
)
vals.update({"split_time_materials": split_time_materials})
return vals

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="industry_fsm.task_custom_report" model="ir.actions.report">
<field name="print_report_name">'%s %s' % (
object.planned_date_begin.strftime(
'%Y-%m-%d') if object.planned_date_begin else time.strftime('%Y-%m-%d'),
object.name
)
</field>
</record>
</odoo>

View file

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1
access_bemade_fsm_task_wizard,access_bemade_fsm_task_wizard,model_project_task_from_template_wizard,project.group_project_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_bemade_fsm_task_template_manager bemade_fsm_task_template model_project_task_template project.group_project_manager 1 1 1 1
3 access_bemade_fsm_task_template_user bemade_fsm_task_template model_project_task_template project.group_project_user 1 1 1 1
4 access_bemade_fsm_visit access_bemade_fsm_visit model_bemade_fsm_visit base.group_user 1 1 1 1
5 access_bemade_fsm_task_wizard access_bemade_fsm_task_wizard model_project_task_from_template_wizard project.group_project_user 1 1 1 1

View file

@ -0,0 +1,37 @@
/** @odoo-module **/
import KanbanController from 'web.KanbanController';
import KanbanView from 'web.KanbanView';
const viewRegistry = require('web.view_registry')
const ProjectKanbanController = KanbanController.extend({
buttons_template: 'project.KanbanView.buttons',
custom_events: _.extend({}, KanbanController.prototype.custom_events, {
create_from_template: '_onCreateFromTemplate',
}),
_onCreateFromTemplate(ev) {
ev.stopPropagation();
const record = this.model.get(this.handle, {raw: true});
let context = record.context;
context.active_id = record.project_id;
this.do_action({
type: 'ir.actions.act_window',
res_model: 'project.task.from.template.wizard',
views: [[false, 'form']],
target: 'new',
context: {...record.context, res_model: 'project.task'},
});
},
renderButtons ($node) {
this._super.apply(this, arguments);
this.$buttons.on('click', 'button.o-kanban-button-new-from-template', this._onCreateFromTemplate.bind(this))
},
})
const ProjectKanbanView = KanbanView.extend({
config: _.extend({}, KanbanView.prototype.config, {
Controller: ProjectKanbanController,
})
});
viewRegistry.add('project_kanban', ProjectKanbanView)

View file

@ -0,0 +1,36 @@
/** @odoo-module **/
import ListController from 'web.ListController';
import ListView from 'web.ListView';
const viewRegistry = require('web.view_registry')
const ProjectListController = ListController.extend({
buttons_template: 'project.ListView.buttons',
custom_events: _.extend({}, ListController.prototype.custom_events, {
create_from_template: '_onCreateFromTemplate',
}),
_onCreateFromTemplate(ev) {
ev.stopPropagation();
const record = this.model.get(this.handle, {raw: true});
this.do_action({
type: 'ir.actions.act_window',
res_model: 'project.task.from.template.wizard',
views: [[false, 'form']],
target: 'new',
context: {...record.context, res_model: 'project.task'},
});
},
renderButtons ($node) {
self = this;
this._super.apply(this, arguments);
this.$buttons.on('click', 'button.o_list_button_add_from_template', this._onCreateFromTemplate.bind(this))
},
})
const ProjectListView = ListView.extend({
config: _.extend({}, ListView.prototype.config, {
Controller: ProjectListController,
})
});
viewRegistry.add('project_list', ProjectListView)

View file

@ -0,0 +1,14 @@
/* This prevents repeating the header, which overlaps with the body due to a bug in
wkhtmltopdf rendering.
*/
thead {
display: table-row-group;
}
tfoot {
display: table-row-group;
}
tr {
page-break-inside: avoid;
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- Add a button to create a task from a template to task (project) list and kanban views -->
<t t-name='project.KanbanView.buttons' t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary">
<xpath expr="//button[contains(@t-attf-class, 'o-kanban-button-new')]" position="after">
<button title="Create Task from Template" t-if="!noCreate" type="button"
t-attf-class="btn {{ btnClass }} o-kanban-button-new-from-template">
<t t-esc="create_text &amp;&amp; (create_text + ' from Template') || _t('Create from Template')"/>
</button>
</xpath>
</t>
<t t-name='project.ListView.buttons' t-inherit="web.ListView.Buttons" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o_list_button_add')]" position="after">
<!-- Create is enabled in the parent template at this point; check is done prior -->
<button type="button" class="btn ml-1 btn-primary o_list_button_add_from_template"
title="Create Task from Template">
Create from Template
</button>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,9 @@
from . import test_bemade_fsm_common
from . import test_task_template
from . import test_sale_order
from . import test_fsm_contact_setting
from . import test_fsm_visit
from . import test_task
from . import test_task_report
from . import test_settings
from . import test_equipment

View file

@ -0,0 +1,292 @@
from odoo.tests.common import TransactionCase, tagged
from odoo import Command
@tagged("-at_install", "post_install")
class BemadeFSMBaseTest(TransactionCase):
@classmethod
def _generate_project_manager_user(cls, name, login):
group_ids = cls.__get_user_groups()
user_group_project_manager = cls.env.ref("project.group_project_manager")
user_group_fsm_manager = cls.env.ref("industry_fsm.group_fsm_manager")
group_ids.append(user_group_fsm_manager.id)
group_ids.append(user_group_project_manager.id)
return cls.__generate_user(name, login, group_ids)
@classmethod
def _generate_project_user(cls, name, login):
group_ids = cls.__get_user_groups()
return cls.__generate_user(name, login, group_ids)
@classmethod
def __generate_user(cls, name, login, group_ids):
return (
cls.env["res.users"]
.with_context(no_reset_password=True)
.create(
{
"name": name,
"login": login,
"password": login,
"email": f"{login}@test.co",
"groups_id": [Command.set(group_ids)],
}
)
)
@classmethod
def __get_user_groups(cls):
user_group_employee = cls.env.ref("base.group_user")
user_group_project_user = cls.env.ref("project.group_project_user")
user_group_fsm_user = cls.env.ref("industry_fsm.group_fsm_user")
user_group_sales_user = cls.env.ref("sales_team.group_sale_salesman")
user_group_sales_manager = cls.env.ref("sales_team.group_sale_manager")
user_group_delivery_address = cls.env.ref(
"account.group_delivery_invoice_address"
)
user_product_customer = cls.env.ref(
"customer_product_code.group_product_customer_code_user", # pyright: ignore[reportGeneralTypeIssues]
raise_if_not_found=False,
)
group_ids = [
user_group_employee.id,
user_group_project_user.id,
user_group_fsm_user.id,
user_group_sales_manager.id,
user_group_sales_user.id,
user_group_delivery_address.id,
]
if user_product_customer:
group_ids.append(user_product_customer.id)
return group_ids
@classmethod
def _generate_partner(
cls,
name: str = "Test Company",
company_type: str = "company",
parent=None,
location_type="other",
):
"""Generates a partner with basic address filled in.
:param name: The partner's name.
:param company_type: The type of partner, either 'company' or 'person' are
accepted.
"""
return cls.env["res.partner"].create(
{
"name": name,
"company_type": company_type,
"street": "123 Street St.",
"city": "Montreal",
"state_id": cls.env.ref("base.state_ca_qc").id,
"country_id": cls.env.ref("base.ca").id,
"parent_id": parent and parent.id or False,
"type": location_type,
}
)
@classmethod
def _generate_sale_order(
cls,
partner=None,
client_order_ref="Test Order",
equipment=None,
shipping_location=None,
):
partner = partner or cls._generate_partner()
vals = {"partner_id": partner.id, "client_order_ref": client_order_ref}
if equipment:
vals.update({"default_equipment_ids": [Command.set([equipment.id])]})
if shipping_location:
vals.update({"partner_shipping_id": shipping_location.id})
return cls.env["sale.order"].create(vals)
@classmethod
def _generate_sale_order_line(
cls, sale_order, product=None, qty=1.0, uom=None, price=100.0, tax_id=False
):
if not product:
product = cls._generate_product()
return cls.env["sale.order.line"].create(
{
"order_id": sale_order.id,
"product_id": product.id,
"product_uom_qty": qty,
"product_uom": uom and uom.id or cls.env.ref("uom.product_uom_hour").id,
"price_unit": price,
"tax_id": tax_id,
}
)
@classmethod
def _generate_equipment(cls, name="test equipment", partner=None):
return cls.env["fsm.equipment"].create(
{
"name": name,
"partner_id": partner and partner.id or False,
}
)
@classmethod
def _generate_product(
cls,
name="Test Product",
product_type="service",
service_tracking="task_global_project",
project=None,
task_template=None,
service_policy="delivered_manual",
uom=None,
):
if "project" in service_tracking and not project:
project = cls.env.ref("industry_fsm.fsm_project")
uom_id = uom and uom.id or cls.env.ref("uom.product_uom_hour").id or False
return cls.env["product.product"].create(
{
"name": name,
"type": product_type,
"service_tracking": service_tracking,
"service_type": "timesheet",
"project_id": (
service_tracking in ("task_global_project", "project_only")
and project
and project.id
or False
),
"project_template_id": (
service_tracking == "task_in_project"
and project
and project.id
or False
),
"task_template_id": task_template and task_template.id or False,
"service_policy": service_policy,
"uom_id": uom_id,
"uom_po_id": uom_id,
}
)
@classmethod
def _generate_fsm_project(cls, name="Test Project"):
return cls.env["project.project"].create(
{
"name": name,
"allow_material": True,
"allow_timesheets": True,
"allow_quotations": True,
"allow_worksheets": True,
"is_fsm": True,
}
)
@classmethod
def _generate_task_template(
cls,
parent=None,
structure=None,
names=None,
planned_hours=1,
equipment=None,
customer=None,
):
"""Generates a task template with the specified structure and naming.
:param parent: The parent task template for the top-level task template being
generated
:param structure: A list of integers describing the number of tasks for each
level of descendants. An empty list represents only one
top-level task template. If no structure is given, an empty
list will be used in its place.
:param names: The name prefixes to be given to the task templates at each level.
Each prefix will be followed by a sequential integer for its
level. Child 1, Child 2, Grandchild 1, etc. If no names argument
is passed, a default ['Task Template'] argument will be used.
:param planned_hours: The number of planned hours for the top-level task
template being generated.
:param equipment: The equipment to add as linked equipment to the task template.
"""
if not names:
names = ["Task Template"]
if not structure:
structure = []
if len(structure) != len(names) - 1:
raise ValueError(
"The length of the structure argument must contain exactly one element"
" less than the names argument."
)
name = names.pop(0)
template = cls.env["project.task.template"].create(
{
"name": name,
"parent": parent and parent.id or False,
"planned_hours": planned_hours,
"equipment_ids": [Command.set(equipment and [equipment.id] or [])],
"customer": customer and customer.id or False,
}
)
parent = template
while structure:
subtasks = []
for i in range(0, structure[0]):
subtasks.append(
cls.env["project.task.template"].create(
{
"parent": parent.id,
"name": names[0] + f" {i}",
}
)
)
structure.pop(0)
names.pop(0)
parent = subtasks[0]
return template
def _invoice_sale_order(self, so):
wiz = (
self.env["sale.advance.payment.inv"]
.with_context(active_ids=[so.id])
.create({})
)
wiz.create_invoices()
inv = so.invoice_ids[-1]
inv.action_post()
return inv
def _generate_visit(self, sale_order, label="Test Label"):
return self.env["bemade_fsm.visit"].create(
[
{
"sale_order_id": sale_order.id,
"label": label,
}
]
)
def _generate_so_with_one_visit_two_lines(self):
so = self._generate_sale_order()
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so)
sol2 = self._generate_sale_order_line(sale_order=so)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2
def _generate_so_with_one_visit_two_lines_and_descendants(self):
so = self._generate_sale_order()
visit = self._generate_visit(sale_order=so)
task_template = self._generate_task_template(
structure=[2, 2, 2],
names=["Parent", "Child", "Grandchild", "Great-grandchild"],
)
product = self._generate_product(task_template=task_template)
sol1 = self._generate_sale_order_line(sale_order=so, product=product)
sol2 = self._generate_sale_order_line(sale_order=so, product=product)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2

View file

@ -0,0 +1,32 @@
from odoo.addons.bemade_fsm.tests.test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests import tagged, Form
@tagged("-at_install", "post_install")
class TestEquipment(BemadeFSMBaseTest):
def test_equipment_search_domain_on_sale_order(self):
"""Equipment from other clients was showing up in sale order line
equipment choices. Make sure this doesn't happen."""
partner = self._generate_partner()
partner_2 = self._generate_partner()
equipment_1 = self._generate_equipment(partner=partner)
equipment_2 = self._generate_equipment(partner=partner_2)
sale_order = self._generate_sale_order(partner=partner)
product = self._generate_product()
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)
name_search_results = self.env["fsm.equipment"].name_search(
args=[
"&",
["id", "in", sale_order.valid_equipment_ids.ids],
"!",
["id", "in", []],
],
limit=8,
name="test",
operator="ilike",
)
self.assertNotIn(
(equipment_2.id, equipment_2.display_name), name_search_results
)
self.assertIn((equipment_1.id, equipment_1.display_name), name_search_results)

View file

@ -0,0 +1,129 @@
from odoo.tests import tagged, Form
from odoo import Command
from .test_bemade_fsm_common import BemadeFSMBaseTest
@tagged("-at_install", "post_install")
class SaleOrderFSMContactsCase(BemadeFSMBaseTest):
def test_site_contacts(self):
parent_co = self._generate_partner("Parent Co")
contact_1 = self._generate_partner("Contact 1", "person", parent_co)
contact_2 = self._generate_partner("Contact 2", "person", parent_co)
# Make sure the SO pulls the defaults from the partner correctly
parent_co.write({"site_contacts": [Command.set([contact_1.id, contact_2.id])]})
so = self._generate_sale_order(parent_co)
self.assertTrue(so.site_contacts == parent_co.site_contacts)
# Make sure updating the site contacts on the SO doesn't feed back to the
# partner
so.write({"site_contacts": [Command.set([contact_1.id])]})
self.assertTrue(contact_1 in so.site_contacts)
self.assertTrue(contact_2 not in so.site_contacts)
self.assertTrue(
so.site_contacts != so.partner_id.site_contacts
and len(so.partner_id.site_contacts) == 2
)
def test_default_workorder_contacts(self):
parent_co = self._generate_partner("Parent Co")
contact_1 = self._generate_partner("Contact 1", "person", parent_co)
contact_2 = self._generate_partner("Contact 2", "person", parent_co)
# Make sure the SO pulls the defaults from the partner correctly
parent_co.write(
{"work_order_contacts": [Command.set([contact_1.id, contact_2.id])]}
)
so = self._generate_sale_order(parent_co)
self.assertTrue(contact_1 in parent_co.work_order_contacts)
self.assertTrue(contact_2 in parent_co.work_order_contacts)
self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(contact_2 in so.work_order_contacts)
# Make sure setting the work order contacts on the SO doesn't feed back to the
# partner
so.write({"work_order_contacts": [Command.set([contact_1.id])]})
self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(contact_2 not in so.work_order_contacts)
self.assertTrue(
so.work_order_contacts != so.partner_id.work_order_contacts
and len(so.partner_id.work_order_contacts) == 2
)
def test_multilayer_site_contacts(self):
parent_co = self._generate_partner("Parent Co")
shipping_location = self._generate_partner(
"Shipping Location", "company", parent_co, "delivery"
)
wo_contact_1 = self._generate_partner(
"WO Contact 1", "person", shipping_location
)
wo_contact_2 = self._generate_partner(
"WO Contact 2", "person", shipping_location
)
site_contact_1 = self._generate_partner(
"Site Contact 1", "person", shipping_location
)
site_contact_2 = self._generate_partner(
"Site Contact 2", "person", shipping_location
)
shipping_location.write(
{
"work_order_contacts": [
Command.set([wo_contact_1.id, wo_contact_2.id])
],
"site_contacts": [Command.set([site_contact_1.id, site_contact_2.id])],
}
)
so = self._generate_sale_order(parent_co)
so.write({"partner_shipping_id": shipping_location.id})
self.assertEqual(so.site_contacts, shipping_location.site_contacts)
self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts)
def test_onchange_shipping_address(self):
self.env.user.groups_id += self.env.ref(
"account.group_delivery_invoice_address"
)
parent_co = self._generate_partner("Parent Co")
shipping_location = self._generate_partner(
"Shipping Location", "company", parent_co, "delivery"
)
wo_contact_1 = self._generate_partner(
"WO Contact 1", "person", shipping_location
)
wo_contact_2 = self._generate_partner(
"WO Contact 2", "person", shipping_location
)
site_contact_1 = self._generate_partner(
"Site Contact 1", "person", shipping_location
)
site_contact_2 = self._generate_partner(
"Site Contact 2", "person", shipping_location
)
shipping_location.write(
{
"work_order_contacts": [
Command.set([wo_contact_1.id, wo_contact_2.id])
],
"site_contacts": [Command.set([site_contact_1.id, site_contact_2.id])],
}
)
so = self._generate_sale_order(parent_co)
# Set back to a location without site or work order contacts
form = Form(so)
form.partner_shipping_id = parent_co
form.save()
# Make sure the contacts were reset on the SO
self.assertFalse(so.work_order_contacts)
self.assertFalse(so.site_contacts)
# Now set back to the location with the FSM contacts and make sure they get set
# on the SO
form.partner_shipping_id = shipping_location
form.save()
self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts)
self.assertEqual(so.site_contacts, shipping_location.site_contacts)

View file

@ -0,0 +1,196 @@
from odoo.tests import tagged
from .test_bemade_fsm_common import BemadeFSMBaseTest
from datetime import date, timedelta
@tagged("-at_install", "post_install")
class FSMVisitTest(BemadeFSMBaseTest):
def test_create_visit_sets_name_on_section(self):
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
visit = self._generate_visit(so)
self.assertTrue(visit.so_section_id)
self.assertEqual(visit.so_section_id.name, visit.label)
def test_change_visit_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
line.name = "Second Label"
self.assertEqual(visit.label, "Second Label")
def test_change_visit_label_changes_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
visit.label = "Second Label"
self.assertEqual(line.name, "Second Label")
def test_visit_completes_when_task_completes(self):
so = self._generate_sale_order()
visit = self._generate_visit(so)
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda line: line.task_id).task_id
task.action_fsm_validate()
self.assertTrue(visit.is_completed)
def test_visit_shows_invoiced_when_invoiced(self):
so = self._generate_sale_order()
visit = self._generate_visit(so)
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda line: line.task_id).task_id
task.action_fsm_validate()
self._invoice_sale_order(so)
self.assertTrue(visit.is_invoiced)
def test_visit_groups_section_tasks_when_confirmed(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertTrue(visit_task)
visit_subtasks = visit_task.child_ids
self.assertTrue(
visit_subtasks
and sol1.task_id in visit_subtasks
and sol2.task_id in visit_subtasks
)
def test_visit_task_gets_correct_due_date_on_confirmation(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(visit_task.date_deadline, visit.approx_date)
def test_visit_task_gets_correct_duration_on_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so, qty=4.0)
sol2 = self._generate_sale_order_line(sale_order=so, qty=4.0)
visit.approx_date = date.today() + timedelta(days=7)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(sol1.task_id.allocated_hours, 4.0)
self.assertEqual(sol2.task_id.allocated_hours, 4.0)
self.assertEqual(visit_task.allocated_hours, 8.0)
def test_adding_visit_creates_one_sale_order_line(self):
self._generate_partner()
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
self._generate_sale_order_line(sale_order=so)
self._generate_visit(sale_order=so)
self.assertEqual(len(so.order_line), 3)
def test_marking_visit_task_done_completes_descendants(self):
(
so,
visit,
sol1,
sol2,
) = self._generate_so_with_one_visit_two_lines_and_descendants()
so.action_confirm()
parent = visit.task_id
parent.action_fsm_validate()
self._assert_is_done(parent)
def _assert_is_done(self, task):
"""Recursively assert all tasks in a hierarchy are complete"""
self.assertTrue(task.is_closed)
for child in task.child_ids:
self._assert_is_done(child)
def test_marking_visit_task_done_does_not_create_sale_order_line(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit.task_id.action_fsm_validate()
self.assertEqual(len(so.order_line), 3)
def test_confirming_so_names_visit_properly(self):
"""Visits should be named <SO NUMBER> - Visit <visit #> - <visit label>"""
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.name = "SO12345"
so.action_confirm()
task = visit.task_id
supposed_name = "SVR12345-1 - Test Company - Test Label"
self.assertEqual(task.name, supposed_name)
def test_subtasks_inherit_partner_from_parent_task(self):
"""Test that subtasks of tasks created from FSM sales orders have their partner_id set correctly."""
# Create a sale order with a shipping address different from the billing address
partner = self._generate_partner(name="Customer")
shipping_partner = self.env['res.partner'].create({
'name': 'Shipping Address',
'parent_id': partner.id,
'type': 'delivery',
})
# Create a sale order with the customer and shipping address
so = self._generate_sale_order(partner=partner)
so.partner_shipping_id = shipping_partner
# Create a visit
visit = self._generate_visit(sale_order=so)
# Create a product with a task template that has subtasks
parent_template = self._generate_task_template(
structure=[1],
names=["Parent Task", "Child Task"],
)
product = self._generate_product(task_template=parent_template)
# Add the product to the sale order
sol = self._generate_sale_order_line(sale_order=so, product=product)
# Set the sequence to ensure proper ordering
visit.so_section_id.sequence = 1
sol.sequence = 2
# Confirm the sale order to create tasks
so.action_confirm()
# Get the created tasks
parent_task = sol.task_id
self.assertTrue(parent_task, "Parent task should be created")
# Check that the parent task has a partner_id set
self.assertTrue(parent_task.partner_id, "Parent task should have a partner set")
# Check that the subtask exists
self.assertTrue(parent_task.child_ids, "Parent task should have subtasks")
child_task = parent_task.child_ids[0]
# The key test: Check that the subtask has a partner_id set
self.assertTrue(child_task.partner_id, "Subtask should have a partner_id set")
# Check that the subtask has the same partner as the parent task
self.assertEqual(child_task.partner_id, parent_task.partner_id,
"Subtask should have the same partner as its parent task")

View file

@ -0,0 +1,474 @@
from .test_task_template import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo import Command
@tagged("-at_install", "post_install")
class TestSalesOrder(BemadeFSMBaseTest):
@tagged("-at_install", "post_install")
def test_order_confirmation_simple_template(self):
"""Confirming the order should create a task in the global project based on the
task template."""
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
task = sol.task_id
self.assertTrue(task)
self.assertTrue(task_template.name in task.name)
self.assertTrue(task_template.planned_hours == task.allocated_hours)
def test_task_template_tree_order_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
parent_template = self._generate_task_template(
structure=[2, 1],
names=["Parent Template", "Child Template", "Grandchild Template"],
)
child_template_1 = parent_template.subtasks[0]
child_template_2 = parent_template.subtasks[1]
grandchild_template = parent_template.subtasks[0].subtasks[0]
product = self._generate_product(task_template=parent_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent = sol.task_id
c1, c2 = parent.child_ids
gc = c1.child_ids[:1]
self.assertTrue(parent.child_ids and len(parent.child_ids) == 2)
self.assertTrue(parent_template.name in parent.name)
self.assertEqual(child_template_1.name, c1.name)
self.assertEqual(child_template_2.name, c2.name)
self.assertTrue(c1.child_ids and len(c1.child_ids) == 1)
self.assertEqual(grandchild_template.name, gc.name)
def test_order_confirmation_single_equipment(self):
"""The equipment selected on the SO should transfer to the task."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner, equipment=equipment)
task_template = self._generate_task_template(planned_hours=8)
product1 = self._generate_product(task_template=task_template)
product2 = self._generate_product()
sol1 = self._generate_sale_order_line(so, product=product1)
sol2 = self._generate_sale_order_line(so, product=product2)
so.action_confirm()
task1 = sol1.task_id
task2 = sol2.task_id
self.assertEqual(task1.equipment_ids[0], equipment)
self.assertEqual(task2.equipment_ids[0], equipment)
def test_order_confirmation_multiple_equipment(self):
"""All equipment items should flow from the sale order line to the final task"""
partner = self._generate_partner()
for i in range(5):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(
partner=partner
) # No default equipment since more than 3 on partner
sol1, sol2, sol3 = [
self._generate_sale_order_line(sale_order=sale_order) for _ in range(3)
]
sol1.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2)])
]
sol3.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2, 5)])
]
sale_order.action_confirm()
self.assertEqual(sol1.equipment_ids, sol1.task_id.equipment_ids)
self.assertEqual(sol2.equipment_ids, sol2.task_id.equipment_ids)
self.assertEqual(sol3.equipment_ids, sol3.task_id.equipment_ids)
def test_task_template_with_equipment_flow(self):
"""The equipment selected on a task template should flow down to the task
created on SO confirmation."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(equipment=equipment)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
self.assertEqual(sol.task_id.equipment_ids[0], equipment)
def test_sale_order_line_gets_default_equipment(self):
"""Sale order lines created on a SO with default equipment set should inherit
that default equipment."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(sale_order=sale_order)
self.assertEqual(sol.equipment_ids, partner.equipment_ids)
def test_sale_order_gets_correct_default_equipment_from_partner(self):
"""Should pick up equipment from the partner."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
self.assertEqual(sale_order.default_equipment_ids, partner.owned_equipment_ids)
def test_sale_order_default_equipment_maximum_number(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(3):
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
def test_sale_order_no_default_equipment_with_more_than_three_owned_on_partner(
self,
):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(4):
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, self.env["fsm.equipment"])
def test_sale_order_resets_default_equipment_on_partner_change(self):
partner_1 = self._generate_partner()
partner_2 = self._generate_partner()
self._generate_equipment(partner=partner_1)
sale_order = self._generate_sale_order(partner_1)
form = Form(sale_order)
form.partner_id = partner_2
form.save()
self.assertFalse(sale_order.default_equipment_ids)
def test_sale_order_prioritize_shipping_location_equipments(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent, location_type="delivery")
self._generate_equipment(partner=parent)
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent, shipping_location=child)
self.assertEqual(sale_order.default_equipment_ids, child.equipment_ids)
def test_default_equipment_transfers_to_sale_order_line(self):
partner = self._generate_partner()
for i in range(3):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
for i in range(3):
self._generate_sale_order_line(sale_order=sale_order)
for line in sale_order.order_line:
self.assertEqual(line.equipment_ids, partner.equipment_ids)
def test_task_mark_done(self):
"""Marking the task linked to a SO line should mark the line delivered.
Marking sub-tasks done should not."""
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(
structure=[2], names=["Parent Task", "Subtask"]
)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent_task = sol.task_id
subtasks = parent_task._get_all_subtasks()
# Marking the subtasks done should not increment delivered quantity
subtasks.action_fsm_validate(True)
self.assertEqual(sol.qty_delivered, 0)
# Marking the top-level tasks done should set the delivered quantity to some
# non-zero value based on the UOM
parent_task.action_fsm_validate(True)
self.assertTrue(sol.qty_delivered != 0)
def test_task_contacts_through_sale_order(self):
"""Make sure the site contacts and work order contacts transfer correctly
from the SO to the task."""
partner = self._generate_partner()
contact1 = self._generate_partner("Site contact", "person", partner)
contact2 = self._generate_partner("Work order contact", "person", partner)
partner.write(
{
"site_contacts": [Command.set([contact1.id])],
"work_order_contacts": [Command.set([contact2.id])],
}
)
so = self._generate_sale_order(partner)
product = self._generate_product()
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(so.work_order_contacts, partner.work_order_contacts)
self.assertEqual(so.site_contacts, partner.site_contacts)
self.assertEqual(sol.task_id.work_order_contacts, partner.work_order_contacts)
self.assertEqual(sol.task_id.site_contacts, partner.site_contacts)
def test_tasks_created_at_order_confirmation_have_no_assignees(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
user = self._generate_project_user(name="User", login="login")
# We test as a specific user since testing as root may not produce the error
so.with_user(user).action_confirm()
visit_task = visit.task_id
subtask1 = visit_task.child_ids[0]
subtask2 = visit_task.child_ids[1]
self.assertFalse(visit_task.user_ids)
self.assertFalse(subtask1.user_ids)
self.assertFalse(subtask2.user_ids)
def test_long_line_name_overflows_to_task_description(self):
so = self._generate_sale_order()
product = self._generate_product()
product.description_sale = (
"This is a long product description.\n"
"It even spans multiple lines.\n"
"One could find this annoying in a task name."
)
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertFalse("This is a long product description." in task.name)
self.assertFalse("It even spans multiple lines." in task.name)
self.assertFalse("One could find this annoying in a task name." in task.name)
self.assertTrue("It even spans multiple lines." in task.description)
self.assertTrue(
"One could find this annoying in a task name." in task.description
)
def test_subtask_templates_no_description_if_blank_on_template(self):
so = self._generate_sale_order()
template = self._generate_task_template(
structure=[5], names=["Parent", "Child"]
)
template.description = ""
template.subtasks[0].description = "Some fixed description"
for t in template.subtasks[1:]:
t.description = ""
product = self._generate_product(task_template=template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertEqual(
task.child_ids[0].description, template.subtasks[0].description
)
for t in task.child_ids[1:]:
self.assertFalse(t.description)
def test_duplicate_sale_order_duplicates_visits(self):
"""Duplicated sales orders should have visits tied to their SO lines as in the
original. The copied visits should not have approximate dates set, however."""
so, visit, line1, line2 = self._generate_so_with_one_visit_two_lines()
so2 = so.copy()
self.assertTrue(so2.visit_ids)
visit2 = so2.visit_ids[0]
self.assertEqual(so2.order_line[0].visit_id, visit2)
self.assertEqual(visit2.label, visit.label)
self.assertFalse(visit2.approx_date)
def test_confirming_sale_order_creates_visit_if_none_created(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
product = self._generate_product()
self._generate_sale_order_line(so, product)
so.action_confirm()
visit_line = so.order_line.sorted("sequence")[0]
self.assertTrue(so.visit_ids)
self.assertEqual(visit_line.visit_id, so.visit_ids)
def test_confirming_sale_order_with_visit_creates_no_new_lines(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
self._generate_product()
self._generate_visit(so)
so.action_confirm()
self.assertEqual(len(so.visit_ids), 1)
def test_confirming_sale_order_creates_no_visit_if_setting_off(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = False
product = self._generate_product()
self._generate_sale_order_line(so, product)
so.action_confirm()
self.assertFalse(so.visit_ids)
def test_changing_sale_order_customer_follows_to_tasks_and_subtasks(self):
so = self._generate_sale_order()
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = False
task_template = self._generate_task_template(
parent=None,
structure=[2, 2],
names=["Parent", "Child", "Grandchild"],
)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product)
so.action_confirm()
parent_task = sol.task_id
for task in parent_task._get_all_subtasks() | parent_task:
self.assertEqual(so.partner_shipping_id, task.partner_id)
so.write(
{
"partner_shipping_id": self.env["res.partner"]
.create(
{
"name": "New shipping address",
"parent_id": so.partner_id.id,
"type": "delivery",
}
)
.id
}
)
for task in parent_task._get_all_subtasks() | parent_task:
self.assertEqual(
so.partner_shipping_id,
task.partner_id,
f"{task.name} has a different partner than the SO",
)
def test_task_hierarchy_maintained_after_cancel_reconfirm(self):
"""Test that task hierarchy and project assignments are maintained when canceling
and reconfirming a sale order with a templated FSM product."""
self.env.user.groups_id |= self.env.ref(
"account.group_delivery_invoice_address"
)
# Create a task template with subtasks
parent_template = self._generate_task_template(
structure=[2], # Two subtasks
names=["Main Service", "Subtask"],
planned_hours=8,
)
# Create FSM product with template
product = self._generate_product(task_template=parent_template)
# Create and confirm sale order
partner = self._generate_partner()
partner_2 = self._generate_partner(parent=partner, company_type="person")
self.assertEqual(partner_2.commercial_partner_id, partner)
so = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
# Get initial tasks and verify setup
main_task = sol.task_id
self.assertTrue(main_task, "Main task should be created")
self.assertTrue(main_task.project_id, "Main task should have a project")
subtasks = main_task.child_ids
self.assertEqual(len(subtasks), 2, "Should have created 2 subtasks")
self.assertEqual(so.tasks_count, 1, "Should have only 1 task on confirmation")
# Verify initial task hierarchy
initial_project = main_task.project_id
for subtask in subtasks:
self.assertEqual(
subtask.project_id,
initial_project,
"Subtask should have same project as main task",
)
self.assertFalse(
subtask.sale_order_id, "Subtask should not be linked to sale order"
)
self.assertFalse(
subtask.sale_line_id, "Subtask should not be linked to sale order line"
)
# Store initial names for comparison
initial_subtask_names = subtasks.mapped("name")
original_task_names = (main_task | main_task._get_all_subtasks()).mapped("name")
# Cancel and reconfirm the sale order
so.with_context(disable_cancel_warning=True).action_cancel()
so.action_draft()
so.write({"partner_shipping_id": partner_2.id})
# Get new tasks
new_main_task = sol.task_id
self.assertEqual(
new_main_task, main_task, "New main task should be same as old"
)
new_subtasks = new_main_task.child_ids
new_subtasks._compute_sale_line()
self.assertEqual(
len(new_subtasks), 2, "Should still have 2 subtasks after reconfirmation"
)
self.assertFalse(
new_subtasks.sale_line_id,
"Subtasks should not be linked to Sale Order Line",
)
new_task_names = (new_main_task | new_main_task._get_all_subtasks()).mapped(
"name"
)
self.assertEqual(
new_task_names, original_task_names, "New task names should be the same"
)
# Verify task hierarchy is maintained
self.assertEqual(
new_main_task.project_id,
initial_project,
"New main task should have same project",
)
for subtask in new_subtasks:
self.assertEqual(
subtask.project_id,
initial_project,
"New subtask should maintain same project as main task",
)
self.assertFalse(
subtask.sale_order_id, "New subtask should not be linked to sale order"
)
self.assertFalse(
subtask.sale_line_id,
"New subtask should not be linked to sale order line",
)
# Verify subtask names are maintained
self.assertEqual(
sorted(new_subtasks.mapped("name")),
sorted(initial_subtask_names),
"Subtask names should be maintained after reconfirmation",
)

View file

@ -0,0 +1,48 @@
from odoo.tests import TransactionCase, Form, tagged
@tagged("-at_install", "post_install")
class TestSettings(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.test_partner_co = cls.env["res.partner"].create(
{
"name": "Test Co",
}
)
cls.test_co = cls.env["res.company"].create(
{
"name": "Test Co",
"country_id": cls.env.ref("base.ca").id,
}
)
cls.env.user.company_id = cls.test_co
def test_enabling_separate_time_on_work_orders(self):
wizard = self.env["res.config.settings"].create({})
self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders)
with Form(wizard) as form:
form.separate_time_on_work_orders = True
self.assertTrue(self.test_co.split_time_from_materials_on_service_work_orders)
def test_disabling_separate_time_on_work_orders(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.split_time_from_materials_on_service_work_orders = True
with Form(wizard) as form:
form.separate_time_on_work_orders = False
self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders)
def test_enabling_create_default_fsm_visit(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.create_default_fsm_visit = False
with Form(wizard) as form:
form.create_default_fsm_visit = True
self.assertTrue(self.test_co.create_default_fsm_visit)
def test_disabling_create_default_fsm_visit(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.create_default_fsm_visit = True
with Form(wizard) as form:
form.create_default_fsm_visit = False
self.assertFalse(self.test_co.create_default_fsm_visit)

View file

@ -0,0 +1,154 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo import Command
@tagged("post_install", "-at_install")
class TaskTest(BemadeFSMBaseTest):
@classmethod
def setUpClass(cls):
# Chose to set up all tests the same way since this code was becoming very
# redundant
super().setUpClass()
cls.user = cls._generate_project_manager_user("Bob", "Bob")
def _generate_so_with_multilevel_task_template(self):
so = self._generate_sale_order()
template = self._generate_task_template(
names=["Parent", "Child", "Grandchild"], structure=[2, 1]
)
product = self._generate_product(task_template=template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
return so, sol
def test_reassigning_assignment_propagating_task_changes_subtasks(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
task.propagate_assignment = True
task.write(
{
"user_ids": [Command.set([self.user.id])],
"propagate_assignment": True,
}
)
self.assertTrue(
all([t.user_ids == self.user for t in task | task._get_all_subtasks()])
)
def test_reassigning_task_doesnt_propagate_by_default(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
task.write(
{
"user_ids": [Command.set([self.user.id])],
}
)
self.assertFalse(any([t.user_ids for t in task.child_ids.child_ids]))
def test_unset_propagate_assignment_unsets_for_all_children(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
# First, set propagation and assign
task.propagate_assignment = True
task.write({"user_ids": [Command.set([self.user.id])]})
# Then, unset propagation for the children and re-set assignment
task.child_ids.write({"propagate_assignment": False})
self.assertFalse(
any([t.propagate_assignment for t in task._get_all_subtasks()])
)
# Then, test that assigning the parent only assigns its children, not its
# grandchildren
task.write({"user_ids": [Command.set([])]})
self.assertTrue(all([not t.user_ids for t in task | task.child_ids]))
self.assertTrue(
all([t.user_ids == self.user for t in task.child_ids.child_ids])
)
def test_task_gets_work_order_contacts_from_sale_order(self):
so, sol = self._generate_so_with_multilevel_task_template()
work_order_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]})
so.action_confirm()
task = sol.task_id
self.assertEqual(task.work_order_contacts, so.work_order_contacts)
# Just a safeguard to make sure we set it properly on the SO
self.assertEqual(len(task.work_order_contacts), 2)
# Make sure all subtasks got the same
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.work_order_contacts, so.work_order_contacts)
def test_task_gets_site_contacts_from_sale_order(self):
so, sol = self._generate_so_with_multilevel_task_template()
site_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"site_contacts": [(6, 0, site_contacts.ids)]})
so.action_confirm()
task = sol.task_id
self.assertEqual(task.site_contacts, so.site_contacts)
# Just a safeguard to make sure we set it properly on the SO
self.assertEqual(len(task.site_contacts), 2)
# Make sure all subtasks got the same
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.site_contacts, so.site_contacts)
def test_task_gets_work_order_contacts_from_parent(self):
so, sol = self._generate_so_with_multilevel_task_template()
work_order_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]})
so.action_confirm()
task = sol.task_id
task.write(
{
"work_order_contacts": [
Command.link(self._generate_partner(parent=so.partner_id).id)
]
}
)
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.work_order_contacts, task.work_order_contacts)
with Form(task) as task_form:
with task_form.child_ids.new() as subtask:
subtask.name = "Subtask 1"
subtask = task.child_ids[-1]
self.assertEqual(subtask.work_order_contacts, task.work_order_contacts)
def test_task_gets_site_contacts_from_parent(self):
so, sol = self._generate_so_with_multilevel_task_template()
site_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"site_contacts": [(6, 0, site_contacts.ids)]})
so.action_confirm()
task = sol.task_id
task.write(
{
"site_contacts": [
Command.link(self._generate_partner(parent=so.partner_id).id)
]
}
)
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.site_contacts, task.site_contacts)
with Form(task) as task_form:
with task_form.child_ids.new() as subtask:
subtask.name = "Subtask 1"
subtask = task.child_ids[-1]
self.assertEqual(subtask.site_contacts, task.site_contacts)

View file

@ -0,0 +1,62 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests import Form
class TestTaskReport(BemadeFSMBaseTest):
def test_split_time_materials_setting(self):
with Form(self.env["res.config.settings"]) as settings:
settings.separate_time_on_work_orders = True
with Form(self.env["res.config.settings"]):
self.assertTrue(settings.separate_time_on_work_orders)
so = self._generate_sale_order()
service_product = self._generate_product()
material_product = self._generate_product(
name="Material Product",
product_type="consu",
service_tracking="no",
)
visit = self._generate_visit(sale_order=so)
self._generate_sale_order_line(sale_order=so, product=service_product)
self._generate_sale_order_line(sale_order=so, product=material_product)
so.action_confirm()
task = visit.task_id
# Add timesheet entry to make the report renderable
self.env["account.analytic.line"].create(
{
"name": "Test timesheet entry",
"task_id": task.id,
"project_id": task.project_id.id,
"unit_amount": 2.0,
"employee_id": self.env["hr.employee"]
.create(
{
"name": "Test Employee",
"user_id": self.env.user.id,
}
)
.id,
}
)
html_content = (
self.env["ir.actions.report"]
._render(
"industry_fsm.worksheet_custom", [task.id]
)[ # pyright: ignore[reportOptionalSubscript]
0
]
.decode("utf-8")
.split("\n")
)
strings_to_find = ["<h2>Materials</h2>", "<span>Material Product</span>"]
for line in strings_to_find:
line_found = False
for html_line in html_content:
if line in html_line:
line_found = True
self.assertTrue(line_found, f"{line} should be in file.")

View file

@ -0,0 +1,80 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo.exceptions import MissingError
from odoo.tools import mute_logger
import psycopg2
@tagged("-at_install", "post_install")
class TestTaskTemplate(BemadeFSMBaseTest):
def test_delete_task_template(self):
"""User should never be able to delete a task template used on a product"""
task_template = self._generate_task_template(names=["Template 1"])
self._generate_product(name="Test Product 1", task_template=task_template)
with self.assertRaises(psycopg2.errors.ForeignKeyViolation):
with mute_logger("odoo.sql_db"):
task_template.unlink()
def test_delete_subtask_template(self):
"""Deletion of a child task should be OK even if the parent is on a product.
Children of the deleted subtask should be deleted."""
parent_task = self._generate_task_template(
structure=[2, 1],
names=["Parent Template", "Child Template", "Grandchild Template"],
)
grandchild_task = parent_task.subtasks[0].subtasks[0]
parent_task.subtasks[0].unlink()
# Reading deleted child's name field should be impossible
with self.assertRaises(MissingError):
_ = grandchild_task.name
def test_dissociating_customer_resets_equipment_appropriately(self):
partner1 = self._generate_partner()
partner2 = self._generate_partner()
equipment1 = self._generate_equipment(partner=partner1)
task = self._generate_task_template(customer=partner1, equipment=equipment1)
form = Form(task)
# Switching the partner should trigger on_change that makes sure equipments are
# linked to the new partner
form.customer = partner2
form.save()
self.assertFalse(equipment1 in task.equipment_ids)
def test_child_task_names_are_short_version(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
template = self._generate_task_template(names=["Task"])
product = self._generate_product(task_template=template)
sol1.name = "Short Name 1"
sol2.name = "Short Name 2"
sol3 = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(sol1.task_id.name, "Short Name 1")
self.assertEqual(sol2.task_id.name, "Short Name 2")
self.assertEqual(sol3.task_id.name, "Task")
def test_task_creation_directly_from_template(self):
project = self.env.ref("industry_fsm.fsm_project")
template = self._generate_task_template(
names=["Task", "Child", "Grandchild"], structure=[2, 1]
)
task = template.create_task_from_self(project, "My new task")
self.assertEqual(len(task.child_ids), len(template.subtasks))
self.assertEqual(
len(task.child_ids[0].child_ids), len(template.subtasks[0].subtasks)
)
self.assertEqual(
len(task.child_ids[1].child_ids), len(template.subtasks[1].subtasks)
)
self.assertEqual(task.name, "My new task")
self.assertEqual(task.child_ids[0].name, template.subtasks[0].name)
self.assertTrue(
all([t.project_id == project for t in task | task._get_all_subtasks()])
)

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<menuitem
id="project_task_template_menu"
name="Task Templates"
parent="project.menu_main_pm"
action="task_template_act_window"
groups="project.group_project_manager,project.group_project_user"
/>
<menuitem
id="service_task_template_meny"
name="Task Templates"
action="task_template_act_window"
parent="industry_fsm.fsm_menu_root"
groups="project.group_project_manager,industry_fsm.group_fsm_manager"
/>
</odoo>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="product_template_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.product.template.form</field>
<field name="model">product.template</field>
<field
name="inherit_id"
ref="sale_project.product_template_form_view_invoice_policy_inherit_sale_project"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='project_id']" position="after">
<field
name="task_template_id"
invisible="service_tracking not in ('task_global_project', 'task_in_project')"
domain="[('parent', '=', False)]"
/>
</xpath>
</field>
</record>
<!-- BV: Did comment that one cause can not match inherit_id and don't understand it use -->
<!-- <record id="product_search_form_view_inherit_bemade_fsm" model="ir.ui.view">-->
<!-- <field name="name">bemade_fsm.product_search_form_view_inherit_bemade_fsm</field>-->
<!-- <field name="model">product.product</field>-->
<!-- <field name="inherit_id" ref="industry_fsm_sale.product_search_form_view_inherit_fsm_sale"/>-->
<!-- <field name="arch" type="xml">-->
<!-- <xpath expr="//searchpanel//field[@name='categ_id']" position="attributes">-->
<!-- <attribute name="limit">0</attribute>-->
<!-- </xpath>-->
<!-- </field>-->
<!-- </record>-->
</odoo>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" ?>
<odoo>
<record id="partner_equipment_location_view_form" model="ir.ui.view">
<field name="name">partner.equipment.location.view.form</field>
<field name="model">res.partner</field>
<field
name="inherit_id"
ref="fsm_equipment.partner_equipment_location_view_form"
/>
<field name="arch" type="xml">
<page name="equipment" position="attributes">
<attribute name="invisible">site_ids</attribute>
</page>
<page name="equipment" position="after">
<page
name="Service Contacts"
invisible="site_ids">
<field
name="site_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="work_order_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
</page>
<field name="is_service_site" invisible="True"/>
<page name="Service Sites" invisible="is_service_site">
<group>
<field name="site_ids">
<list editable="bottom">
<field name="name" widget="res_partner_many2one" />
</list>
</field>
</group>
</page>
</page>
</field>
</record>
<record id="fsm_contacts_view_list" model="ir.ui.view">
<field name="name">bemade_fsm.contacts.list</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="name" />
<field name="email" widget="email" />
<field name="phone" widget="phone" />
<field name="mobile" widget="phone" />
</list>
</field>
</record>
</odoo>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_order_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.sale_order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//page[@name='other_information']" position="before">
<page name="field_service" string="Field Service">
<group name="fsm_visits" string="Service Visits">
<field
name="visit_ids"
context="{'list_view_ref': 'bemade_fsm.bemade_fsm_visit_list'}"
/>
</group>
<group name="field_service_info" string="Contacts and Equipment">
<field name="valid_equipment_ids" invisible="1" />
<field
name="summary_equipment_ids"
context="{'default_partner_id': partner_shipping_id,}"
widget="many2many_tags"
groups="account.group_delivery_invoice_address"
/>
<field
name="site_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="work_order_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="default_equipment_ids"
context="{'default_partner_id': partner_shipping_id,}"
widget="many2many_tags"
groups="account.group_delivery_invoice_address"
/>
</group>
</page>
</xpath>
<xpath expr="//list//field[@name='name']" position="after">
<field name="valid_equipment_ids" invisible="1" />
<field
name="equipment_ids"
widget="many2many_tags"
domain="[('id', 'in', valid_equipment_ids)]"
/>
</xpath>
</field>
</record>
<record id="bemade_fsm_visit_list" model="ir.ui.view">
<field name="name">bemade_fsm.visit.list</field>
<field name="model">bemade_fsm.visit</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="label" />
<field name="approx_date" />
<field name="is_completed" widget="boolean" />
<field name="is_invoiced" widget="boolean" />
</list>
</field>
</record>
</odoo>

View file

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="task_template_form_view" model="ir.ui.view">
<field name="name">bemade_fsm.task_template.form</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<form string="Task Template">
<sheet>
<div class="oe_title">
<label for="name" />
<h1>
<field name="name" placeholder="Title" />
</h1>
</div>
<group>
<group>
<field name="project" />
<field name="assignees" widget="many2many_avatar_user" />
<field name="parent" />
<field name="planned_hours" />
</group>
<group>
<field name="customer" />
<field
name="equipment_ids"
domain="[('partner_id', '=', customer)]"
context="{'list_view_ref': 'fsm_equipment.equipment_view_list'}"
/>
<field name="tags" widget="many2many_tags" />
<field name="company_id" />
</group>
</group>
<notebook>
<page name="description_page" string="Description">
<field
name="description"
type="html"
options="{'collaborative': true}"
/>
</page>
<page name="subtasks_page" string="Subtasks">
<field name="subtasks">
<list editable="bottom">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="customer" />
<field
name="assignees"
widget="many2many_avatar_user"
/>
<button
name="action_open_task"
type="object"
title="View Task"
string="View Task"
class="btn btn-link pull-right"
/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="task_template_list_view" model="ir.ui.view">
<field name="name">project.task_template.list</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<list>
<field name="name" />
<field name="assignees" widget="many2many_avatar_user" />
<field name="project" />
<field name="parent" />
<field name="planned_hours" />
</list>
</field>
</record>
<record id="task_template_search_view" model="ir.ui.view">
<field name="name">project.task_template.search</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<search string="Task Template">
<field name="name" />
<field name="project" />
<field name="assignees" />
<field name="parent" />
<field name="subtasks" />
<field name="planned_hours" />
<group expand="1" string="Group By">
<filter
string="Project"
name="groupby_project"
domain="[]"
context="{'group_by':'project'}"
/>
<filter
string="Parent Task"
name="groupby_parent"
domain="[]"
context="{'group_by':'parent'}"
/>
<filter
string="Customer"
name="groupby_customer"
domain="[]"
context="{'group_by':'customer'}"
/>
</group>
</search>
</field>
</record>
<record id="task_template_act_window" model="ir.actions.act_window">
<field name="name">Task Template</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">project.task.template</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
There are no task templates, click above to create one.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,229 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="bemade_fsm_project_task_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.project_task.form</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.view_task_form2_inherit" />
<field name="priority" eval="8" />
<field name="arch" type="xml">
<xpath expr="//field[@name='name']/.." position="before">
<h1 class="d-flex flex-row justify-content-between">
<field
name="work_order_number"
invisible="work_order_number == False"
/>
</h1>
</xpath>
<xpath expr="//page[@name='extra_info']" position="after">
<page string="Equipment and Contacts" name="equipment_contacts"
translate="True">
<group name="equipment_and_contacts">
<field
name="equipment_ids"
domain="[('partner_id', '=', partner_id)]"
context="{'list_view_ref': 'fsm_equipment.equipment_view_list'}"
/>
<field
name="site_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="work_order_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
</group>
</page>
</xpath>
<button
name="action_fsm_validate"
class='btn-primary'
position="attributes"
>
<attribute name="string">Mark as Delivered</attribute>
<attribute name="groups">industry_fsm.group_fsm_manager</attribute>
</button>
<button
name="action_fsm_validate"
class='btn-secondary'
position="attributes"
>
<attribute name="string">Mark as Delivered</attribute>
<attribute name="groups">industry_fsm.group_fsm_manager</attribute>
</button>
</field>
</record>
<record id="view_task_form2_inherit" model="ir.ui.view">
<field name="inherit_id" ref="project.view_task_form2" />
<field name="model">project.task</field>
<field name="name">bemade_fsm.project_task.form2</field>
<field name="arch" type="xml">
<xpath
expr="//field[@name='child_ids']/list//field[@name='name']"
position="after"
>
<field name="description" string="Description/Comments" />
</xpath>
<field name="user_ids" position="after">
<field name="propagate_assignment" />
</field>
</field>
</record>
<!-- Add parent_id = false to domain for My Tasks, All Tasks: To Schedule, All Tasks and To Invoice-->
<record id="industry_fsm.project_task_action_fsm" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
</record>
<record id="project_task_view_list_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.list.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_list_fsm" />
<field name="arch" type="xml">
<list position="attributes">
<attribute name="js_class">project_list</attribute>
</list>
<field name="name" position="before">
<field name="work_order_number" optional="show" />
</field>
<field name="company_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="worksheet_template_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="project_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="progress" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="activity_ids" position="attributes">
<attribute name="optional">hide</attribute>
</field>
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_map" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm.project_task_action_to_schedule_fsm"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_all_fsm" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm_sale.project_task_action_to_invoice_fsm"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False),
('invoice_status', '=', 'to invoice')]
</field>
</record>
<!-- Add parent_id = false to domain for planning actions as well -->
<record
id="industry_fsm.project_task_action_fsm_planning_groupby_user"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm.project_task_action_fsm_planning_groupby_project"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm_report.project_task_action_fsm_planning_groupby_worksheet"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<!-- <record id="project_task_view_calendar_fsm" model="ir.ui.view">-->
<!-- <field name="name">bemade_fsm.project_task_view_calendar_fsm</field>-->
<!-- <field name="inherit_id" ref="industry_fsm.project_task_view_calendar_fsm"/>-->
<!-- <field name="model">project.task</field>-->
<!-- <field name="arch" type="xml">-->
<!-- <field name="user_ids" position="attributes">-->
<!-- <attribute name="filters">1</attribute>-->
<!-- <attribute name="color">user_ids</attribute>-->
<!-- </field>-->
<!-- </field>-->
<!-- </record>-->
<record id="project_task_view_calendar_fsm_no_worksheet" model="ir.ui.view">
<field name="name">bemade_fsm.project_task_view_calendar_no_worksheet</field>
<field
name="inherit_id"
ref="industry_fsm_report.project_task_view_calendar_fsm_worksheet"
/>
<field name="model">project.task</field>
<field name="priority" eval="100" />
<field name="arch" type="xml">
<xpath expr="//field[@name='worksheet_template_id']" position="replace" />
<xpath expr="//calendar" position="attributes">
<attribute name="color">user_ids</attribute>
</xpath>
</field>
</record>
<record id="project_task_view_search_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.search.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_search_fsm" />
<field name="arch" type="xml">
<filter name="schedule" position="attributes">
<attribute name="domain">
[
'&amp;',
('fsm_done', '=', False),
'|',
('user_ids', '=', False),
'&amp;',
('planned_date_start', '=', False),
('date_deadline', '=', False),
]
</attribute>
</filter>
<filter name="my_tasks" position="after">
<filter
name="is_parent_task"
string="Parent Task"
domain="[('parent_id', '=', False)]"
/>
</filter>
</field>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
from . import new_task_from_template
from . import res_config_settings

View file

@ -0,0 +1,57 @@
from odoo import models, fields
class NewTaskFromTemplateWizard(models.TransientModel):
_name = "project.task.from.template.wizard"
_description = "Create Task from Template Wizard"
project_id = fields.Many2one(
comodel_name="project.project",
string="Project",
help="The project the new task should be created in.",
required=True,
)
task_template_id = fields.Many2one(
comodel_name="project.task.template",
string="Task Template",
help="The template to use when creating the new task.",
required=True,
)
new_task_title = fields.Char(
help=(
"The title (name) for the newly created task. If left blank, the name of"
" the template will be used."
),
)
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get("active_id", False)
active_model = self.env.context.get("active_model", False)
if not active_model:
params = self.env.context.get("params", False)
active_model = params and params.get("model", False)
if (
active_model == "project.task.template"
and active_id
and "task_template_id" in fields_list
):
res.update({"task_template_id": active_id})
if active_model == "project.task" and "project_id" in fields_list:
res.update({"project_id": self.env.ref("industry_fsm.fsm_project").id})
return res
def action_create_task_from_template(self):
self.ensure_one()
task = self.task_template_id.create_task_from_self(
self.project_id, self.new_task_title
)
return {
"type": "ir.actions.act_window",
"res_model": "project.task",
"res_id": task.id,
"view_mode": "form",
"target": "current",
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="project_task_from_template_wizard_view_form" model="ir.ui.view">
<field name="name">project.task.from.template.wizard.view.form</field>
<field name="model">project.task.from.template.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="task_template_id"/>
<field name="new_task_title"/>
<field name="project_id"/>
</group>
</sheet>
<footer>
<button class='btn btn-primary' name="action_create_task_from_template" type="object" string="Create Task"/>
<button class='btn btn-secondary' special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="action_new_task_from_template">
<field name="name">New Task from Template</field>
<field name="res_model">project.task.from.template.wizard</field>
<field name="binding_model_id" ref="bemade_fsm.model_project_task_template"/>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View file

@ -0,0 +1,22 @@
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
company_id = fields.Many2one(
comodel_name="res.company",
default=lambda self: self.env.company or self.env.user.company_id,
)
separate_time_on_work_orders = fields.Boolean(
string="Separate Time from Materials on Work Order",
related="company_id.split_time_from_materials_on_service_work_orders",
readonly=False,
)
create_default_fsm_visit = fields.Boolean(
string="Create Default Visit for FSM Sales Orders",
related="company_id.create_default_fsm_visit",
readonly=False,
)

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.bemade.fsm</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="industry_fsm.res_config_settings_view_form"/>
<field name="arch" type="xml">
<setting name="module_industry_fsm_report" position="after">
<setting
name="separate_time_on_work_orders"
help="Separate blocks for materials and time on work order reports."
company_dependent="1">
<field name="separate_time_on_work_orders"/>
</setting>
<setting
name="create_default_fsm_visit"
help="Create a default FSM visit if a service SO doesn't already contain one at confirmation."
company_dependent="1">
<field name="create_default_fsm_visit"/>
</setting>
</setting>
</field>
</record>
</odoo>

View file

@ -1,31 +0,0 @@
# Migration vers Odoo 18.0 - bemade_helpdesk_mailcow_blacklist
## Description
Module d'intégration entre Helpdesk et Mailcow pour la gestion des blacklists
## Fonctionnalités Ajoutées
- Synchronisation des emails blacklistés avec Mailcow
- Gestion des règles de blocage
- Historique des actions de blacklist
## Modèles et Champs Modifiés
- helpdesk.ticket
- Ajout du champ mailcow_blacklisted (boolean)
- Ajout du champ mailcow_blacklist_reason (text)
## Statut Migration
- [ ] A migrer
- [ ] En cours
- [ ] Migré
## Détails Migration
- Vérifier si la fonctionnalité existe déjà dans Odoo 18.0
- Analyser les impacts sur les workflows existants
## Actions Requises
- [ ] Vérifier la compatibilité avec Odoo 18.0
- [ ] Tester les fonctionnalités
- [ ] Mettre à jour la documentation
## Notes
- Ce module nécessite une configuration spécifique de Mailcow

View file

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

View file

@ -0,0 +1,31 @@
#
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : mdurepos@durpro.com)
#
# 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": "Helpdesk One Ticket Per Email",
"version": "18.0.1.0.0",
"summary": "Restrict ticket creation to a single ticket per email received.",
"category": "Helpdesk",
"author": "Bemade Inc.",
"website": "http://www.bemade.org",
"license": "OPL-1",
"depends": ["mail"],
"data": [],
"installable": True,
"auto_install": False,
}

View file

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

View file

@ -0,0 +1,50 @@
from odoo import models, api
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
_inherit = "mail.thread"
@api.model
def _message_route_process(self, message, message_dict, routes):
"""
Process the routes for an incoming message, ensuring only one helpdesk route is used if multiple
helpdesk routes are detected.
This override filters the routes to avoid processing a message multiple times for helpdesk models
(`helpdesk.ticket` and `helpdesk.team`). If multiple helpdesk routes are found, only the first one
is retained, and a log entry is created to indicate the adjustment.
Parameters:
message (object): The incoming message object.
message_dict (dict): Dictionary of parsed message values.
routes (list): List of route tuples, each containing the target model and relevant information.
Returns:
bool: Result of the `_message_route_process` from the superclass.
"""
try:
# Filter routes to keep only those related to helpdesk models if they are present
helpdesk_routes = [
r for r in routes if r[0] in ("helpdesk.ticket", "helpdesk.team")
]
if helpdesk_routes:
_logger.info(
"Messages contained helpdesk routes. Only the first one will be used."
)
# Retain only the first helpdesk route
routes = [helpdesk_routes[0]]
# Call the parent method with potentially modified routes
return super()._message_route_process(message, message_dict, routes)
except Exception as e:
# Log the exception and raise it to ensure errors are traceable
_logger.error(f"An error occurred in _message_route_process: {str(e)}")
raise UserError(
"An unexpected error occurred while processing message routes. Please contact support."
)

View file

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

View file

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
{
"name": "Hide Decimal on unit",
"version": "18.0.0.1.1",
"category": "Extra Tools",
"summary": "Hide decimal on Qty when there is no decimal",
"description": """
Hide decimal on Qty when there is no decimal
""",
"author": "Bemade",
"website": "https://www.bemade.org",
"depends": ["sale", "purchase"],
"data": ["views/sale.xml", "views/purchase.xml"],
"auto_install": False,
"installable": True,
"license": "OPL-1",
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_purchasequotation_document_strip_decimal" inherit_id="purchase.report_purchasequotation_document">
<xpath expr="//tbody//td[hasclass('text-end')]" position="replace">
<td name="td_quantity" class="text-end">
<t t-if="int(order_line.product_uom_qty) == order_line.product_uom_qty">
<span t-esc="'%.0f' % order_line.product_uom_qty"/>
</t>
<t t-else="">
<span t-esc="order_line.product_uom_qty"/>
</t>
<span t-field="order_line.product_uom" groups="uom.group_uom"/>
</td>
</xpath>
</template>
<template id="report_purchaseorder_document_strip_decimal" inherit_id="purchase.report_purchaseorder_document">
<xpath expr="//tbody//td[hasclass('text-end')][1]" position="replace">
<td name="td_quantity" class="text-end">
<t t-if="int(line.product_uom_qty) == line.product_uom_qty">
<span t-esc="'%.0f' % line.product_uom_qty"/>
</t>
<t t-else="">
<span t-esc="line.product_uom_qty"/>
</t>
<span t-field="line.product_uom" groups="uom.group_uom"/>
</td>
</xpath>
</template>
</odoo>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_saleorder_document_strip_decimal" inherit_id="sale.report_saleorder_document">
<td name="td_quantity" position="replace">
<td name="td_quantity" class="text-right">
<t t-if="int(line.product_uom_qty) == line.product_uom_qty">
<span t-esc="'%.0f' % line.product_uom_qty"/>
</t>
<t t-else="">
<span t-esc="line.product_uom_qty"/>
</t>
<span t-field="line.product_uom"/>
</td>
</td>
</template>
</odoo>

View file

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

View file

@ -0,0 +1,36 @@
#
# 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": "Sales Margin on Vendor Price",
"version": "18.0.0.0.5",
"summary": "Enables calculation of sales margins based on vendor pricelists.",
"description": """Adds a new method for calculating """,
"author": "Bemade Inc.",
"website": "https://www.bemade.org",
"license": "LGPL-3",
"depends": [
"sale_stock_margin",
"sale_management",
"stock",
"product_pricelist_supplierinfo",
],
"data": ["views/sale_order.xml"],
"installable": True,
"auto_install": False,
}

View file

@ -0,0 +1,58 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * bemade_margin_vendor_pricelist
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 15.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-19 15:08+0000\n"
"PO-Revision-Date: 2023-10-19 15:08+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual
msgid "Margin"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual
msgid "Margin (%)"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor
msgid "Margin (%) on Vendor Price"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor
msgid "Margin on Vendor Price"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual
msgid "Purchase Price"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order
msgid "Sales Order"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line
msgid "Sales Order Line"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor
msgid "Vendor Price"
msgstr ""

View file

@ -0,0 +1,58 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * bemade_margin_vendor_pricelist
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 15.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-19 15:08+0000\n"
"PO-Revision-Date: 2023-10-19 15:08+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual
msgid "Margin"
msgstr "Marge"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual
msgid "Margin (%)"
msgstr "Marge (%)"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor
msgid "Margin (%) on Vendor Price"
msgstr "Marge (%) sur le prix du fournisseur"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor
msgid "Margin on Vendor Price"
msgstr "Marge sur le prix du fournisseur"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual
msgid "Purchase Price"
msgstr "Prix d'achat"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order
msgid "Sales Order"
msgstr "Commande de vente"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line
msgid "Sales Order Line"
msgstr "Article de commande de vente"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor
msgid "Vendor Price"
msgstr "Prix du fournisseur"

View file

@ -0,0 +1,2 @@
from . import sale_order
from . import sale_order_line

View file

@ -0,0 +1,25 @@
from odoo import models, fields, api, _
from odoo.tools.float_utils import float_is_zero, float_compare
class SaleOrder(models.Model):
_inherit = "sale.order"
gross_profit = fields.Monetary(
"Gross Profit", compute="_compute_margin_actual", store=False
)
gross_profit_percent = fields.Float(
string="Gross Profit (%)",
compute="_compute_margin_actual",
store=False,
aggregator="avg",
)
@api.depends("order_line.gross_profit", "amount_untaxed")
def _compute_margin_actual(self):
for order in self:
order.gross_profit = sum(order.order_line.mapped("gross_profit"))
order.gross_profit_percent = (
order.amount_untaxed and order.gross_profit / order.amount_untaxed
)

View file

@ -0,0 +1,141 @@
from odoo import models, fields, api, _
from odoo.tools.float_utils import float_is_zero, float_compare
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
purchase_price_vendor = fields.Float(
compute="_compute_purchase_price_vendor",
string="Vendor Price",
groups="base.group_user",
digits="Product Price",
)
purchase_price_actual = fields.Float(
compute="_compute_actual_gp",
digits="Product Price",
groups="base.group_user",
string="Purchase Price",
)
gross_profit = fields.Float(
compute="_compute_actual_gp",
digits="Product Price",
groups="base.group_user",
string="Gross Profit",
)
gross_profit_percent = fields.Float(
compute="_compute_actual_gp",
groups="base.group_user",
string="Gross Profit (%)",
)
@api.depends(
"purchase_price",
"purchase_price_vendor",
"move_ids.product_id",
"move_ids.product_id.qty_available",
"move_ids.state",
"qty_to_deliver",
)
def _compute_actual_gp(self):
"""We want to use the gross profit based on average inventory valuation when the
sale order line will be completely fulfilled (or has been fulfilled) from stock.
For product not yet in stock we want to use the vendor price. We can also have
blended calculations (partly on vendor price, partly on stock valuation). This
occurs when an order has been or would be partially fulfilled from available
stock.
:return:
"""
non_product_lines = self.filtered(lambda r: not r.product_id)
non_product_lines.purchase_price_actual = 0.0
non_product_lines.gross_profit = 0.0
non_product_lines.gross_profit_percent = 0.0
for line in self - non_product_lines:
stock_missing = line._determine_missing_stock()
if float_is_zero(
stock_missing, precision_rounding=line.product_uom.rounding
):
# everything is coming from stock, use inventory valuation
line.purchase_price_actual = line.purchase_price
elif (
float_compare(
line.product_uom_qty,
stock_missing,
precision_rounding=line.product_uom.rounding,
)
== 0
):
# everything is coming from the vendor, use vendor pricing
line.purchase_price_actual = line.purchase_price_vendor
else:
# we have a mix, use blended pricing
qty_from_stock = line.product_uom_qty - stock_missing
line.purchase_price_actual = (
(
stock_missing * line.purchase_price_vendor
+ qty_from_stock * line.purchase_price
)
/ line.product_uom_qty
if line.product_uom_qty != 0
else 0
)
line.gross_profit = line.price_subtotal - (
line.purchase_price_actual * line.product_uom_qty
)
line.gross_profit_percent = (
line.price_subtotal and line.gross_profit / line.price_subtotal
if line.price_subtotal != 0
else 0
)
def _determine_missing_stock(self) -> float:
"""Compute how much stock is missing to meet an order line's demand. In the
case of a quotation line, available stock is checked as if the order were to be
placed immediately.
:return: The quantity missing from available stock to fulfill the line, in
the unit of measure matching self.product_uom.
"""
self.ensure_one()
is_order = self.order_id.state == "sale"
qty_available = max(0, self.product_id.qty_available)
if is_order and self.qty_to_deliver <= 0:
return 0
elif is_order and self.qty_to_deliver > 0:
reserved = sum(
[
q.reserved_quantity
for q in self.move_ids.mapped("move_line_ids").mapped("quant_id")
]
)
return max(0, self.qty_to_deliver - reserved - qty_available)
else:
# This is a quotation, don't bother with stock reservations
# Also, if available is negative for some reason,
return max(0, self.product_uom_qty - qty_available)
@api.depends("product_id", "product_id.seller_ids", "product_id.seller_ids.price")
def _compute_purchase_price_vendor(self):
for line in self:
product = line.product_id
suppinfos = product.seller_ids.sorted("sequence")
if not suppinfos:
line.purchase_price_vendor = 0.0
continue
suppinfo = suppinfos[0]
purch_currency = suppinfo.currency_id
to_cur = line.currency_id or line.order_id.currency_id
line.purchase_price_vendor = (
purch_currency._convert(
from_amount=suppinfo.price,
to_currency=to_cur,
company=line.company_id or self.env.company,
date=line.order_id.date_order or fields.Date.today(),
round=False,
)
if to_cur and suppinfo.price
else suppinfo.price
)

View file

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

View file

@ -0,0 +1,390 @@
from odoo.tests import TransactionCase, tagged
from unittest.mock import patch, PropertyMock
@tagged("-at_install", "post_install")
class TestSaleOrderLine(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create a test vendor
cls.vendor = cls.env["res.partner"].create(
{
"name": "Test Vendor",
"supplier_rank": 1,
}
)
# Create a test product
cls.product = cls.env["product.product"].create(
{
"name": "Test Product",
"type": "consu",
"is_storable": True,
"list_price": 100.0,
"standard_price": 80.0,
}
)
# Link supplier info to the product
cls.supplierinfo = cls.env["product.supplierinfo"].create(
{
"partner_id": cls.vendor.id,
"product_tmpl_id": cls.product.product_tmpl_id.id,
"min_qty": 1,
"price": 75.0, # Vendor price
"currency_id": cls.env.ref(
"base.USD"
).id, # Assuming USD for simplicity
}
)
# Create a test sale order
cls.sale_order = cls.env["sale.order"].create(
{
"partner_id": cls.env.ref("base.res_partner_1").id,
# Assuming a default partner
}
)
# Create a test sale order line
cls.sale_order_line = cls.env["sale.order.line"].create(
{
"order_id": cls.sale_order.id,
"product_id": cls.product.id,
"product_uom_qty": 10,
"price_unit": 120.0,
}
)
def test_purchase_price_all_from_stock(self):
"""Test when all quantity is fulfilled from stock."""
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = 10 # All available
# Recompute the fields
self.sale_order_line._compute_actual_gp()
# Assert purchase_price_actual equals purchase_price (from stock)
self.assertEqual(
self.sale_order_line.purchase_price_actual,
self.sale_order_line.purchase_price,
"Purchase price should be from stock when all quantity is available.",
)
# Assert gross_profit is calculated correctly
expected_gross_profit = self.sale_order_line.price_subtotal - (
self.sale_order_line.purchase_price_actual
* self.sale_order_line.product_uom_qty
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
expected_gross_profit,
msg="Gross profit should be correctly calculated from stock purchase price.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def assertSaleOrderComputesLikeSaleOrderLine(self):
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
self.sale_order.gross_profit,
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit_percent,
self.sale_order.gross_profit_percent,
)
def test_purchase_price_all_from_vendor(self):
"""Test when all quantity is fulfilled from vendor."""
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = 0
# Assert purchase_price_actual equals purchase_price_vendor (from vendor)
self.assertEqual(
self.sale_order_line.purchase_price_actual,
self.sale_order_line.purchase_price_vendor,
"Purchase price should be from vendor when all quantity is missing from stock.",
)
# Assert gross_profit is calculated correctly
expected_gross_profit = self.sale_order_line.price_subtotal - (
self.sale_order_line.purchase_price_actual
* self.sale_order_line.product_uom_qty
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
expected_gross_profit,
msg="Gross profit should be correctly calculated from vendor purchase price.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def test_purchase_price_mixed(self):
"""Test when quantity is partially fulfilled from stock and partially from vendor."""
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = 5
# Calculate expected blended purchase price
qty_from_vendor = 5
qty_from_stock = 5
expected_purchase_price = (
(qty_from_vendor * self.sale_order_line.purchase_price_vendor)
+ (qty_from_stock * self.sale_order_line.purchase_price)
) / self.sale_order_line.product_uom_qty
self.assertAlmostEqual(
self.sale_order_line.purchase_price_actual,
expected_purchase_price,
msg="Purchase price should be a blend of stock and vendor prices.",
)
# Assert gross_profit is calculated correctly
expected_gross_profit = self.sale_order_line.price_subtotal - (
expected_purchase_price * self.sale_order_line.product_uom_qty
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
expected_gross_profit,
msg="Gross profit should be correctly calculated from blended purchase price.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def test_negative_available_stock_zero_qty_ordered(self):
"""We had a division by zero error with a zero product_uom_qty field
and a negative available stock. This test aims to root out this and
other possible causes of a div by zero error."""
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = -1 # Negative available stock
# Set product_uom_qty to zero
self.sale_order_line.product_uom_qty = 0
# Recompute the fields
self.sale_order_line._compute_actual_gp()
# Assert gross_profit is 0
self.assertEqual(
self.sale_order_line.gross_profit,
0.0,
"Gross profit should be 0 when product_uom_qty is zero.",
)
# Assert gross_profit_percent is 0
self.assertEqual(
self.sale_order_line.gross_profit_percent,
0.0,
"Gross profit percent should be 0 when product_uom_qty is zero.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def test_purchase_price_all_from_stock_confirmed(self):
"""Test when all quantity is fulfilled from stock."""
self.sale_order.action_confirm()
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = 10 # All available
# Recompute the fields
self.sale_order_line._compute_actual_gp()
# Assert purchase_price_actual equals purchase_price (from stock)
self.assertEqual(
self.sale_order_line.purchase_price_actual,
self.sale_order_line.purchase_price,
"Purchase price should be from stock when all quantity is available.",
)
# Assert gross_profit is calculated correctly
expected_gross_profit = self.sale_order_line.price_subtotal - (
self.sale_order_line.purchase_price_actual
* self.sale_order_line.product_uom_qty
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
expected_gross_profit,
msg="Gross profit should be correctly calculated from stock purchase price.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def test_purchase_price_all_from_vendor_confirmed(self):
"""Test when all quantity is fulfilled from vendor."""
self.sale_order.action_confirm()
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = 0
# Assert purchase_price_actual equals purchase_price_vendor (from vendor)
self.assertEqual(
self.sale_order_line.purchase_price_actual,
self.sale_order_line.purchase_price_vendor,
"Purchase price should be from vendor when all quantity is missing from stock.",
)
# Assert gross_profit is calculated correctly
expected_gross_profit = self.sale_order_line.price_subtotal - (
self.sale_order_line.purchase_price_actual
* self.sale_order_line.product_uom_qty
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
expected_gross_profit,
msg="Gross profit should be correctly calculated from vendor purchase price.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def test_purchase_price_mixed_confirmed(self):
"""Test when quantity is partially fulfilled from stock and partially from vendor."""
self.sale_order.action_confirm()
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = 5
# Calculate expected blended purchase price
qty_from_vendor = 5
qty_from_stock = 5
expected_purchase_price = (
(qty_from_vendor * self.sale_order_line.purchase_price_vendor)
+ (qty_from_stock * self.sale_order_line.purchase_price)
) / self.sale_order_line.product_uom_qty
self.assertAlmostEqual(
self.sale_order_line.purchase_price_actual,
expected_purchase_price,
msg="Purchase price should be a blend of stock and vendor prices.",
)
# Assert gross_profit is calculated correctly
expected_gross_profit = self.sale_order_line.price_subtotal - (
expected_purchase_price * self.sale_order_line.product_uom_qty
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
expected_gross_profit,
msg="Gross profit should be correctly calculated from blended purchase price.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def test_negative_available_stock_zero_qty_ordered_confirmed(self):
"""We had a division by zero error with a zero product_uom_qty field
and a negative available stock. This test aims to root out this and
other possible causes of a div by zero error."""
self.sale_order.action_confirm()
with patch.object(
self.sale_order_line.product_id.__class__,
"qty_available",
new_callable=PropertyMock,
) as mock_qty_available:
mock_qty_available.return_value = -1 # Negative available stock
# Set product_uom_qty to zero
self.sale_order_line.product_uom_qty = 0
# Recompute the fields
self.sale_order_line._compute_actual_gp()
# Assert gross_profit is 0
self.assertEqual(
self.sale_order_line.gross_profit,
0.0,
"Gross profit should be 0 when product_uom_qty is zero.",
)
# Assert gross_profit_percent is 0
self.assertEqual(
self.sale_order_line.gross_profit_percent,
0.0,
"Gross profit percent should be 0 when product_uom_qty is zero.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()
def test_determine_missing_stock_all_reserved(self):
"""Test the actual _determine_missing_stock method when all stock is reserved."""
# Set initial stock for the product
location = self.env["stock.warehouse"].search([])[0].lot_stock_id
quant = self.env["stock.quant"].create(
{
"product_id": self.product.id,
"location_id": location.id,
"inventory_quantity": 10,
"product_uom_id": self.product.uom_id.id,
}
)
quant.action_apply_inventory()
self.sale_order.action_confirm()
# Force the move to be reserved
self.sale_order.picking_ids.action_assign()
# Now, call _determine_missing_stock without mocking
missing_stock = self.sale_order_line._determine_missing_stock()
# Assert that missing_stock is 0
self.assertEqual(
missing_stock,
0,
"_determine_missing_stock should return 0 when all stock is reserved.",
)
# Additionally, verify that purchase_price_actual is from stock
self.sale_order_line._compute_actual_gp()
self.assertEqual(
self.sale_order_line.purchase_price_actual,
self.sale_order_line.purchase_price,
"Purchase price should be from stock when all stock is reserved.",
)
# Assert gross_profit is calculated correctly
expected_gross_profit = self.sale_order_line.price_subtotal - (
self.sale_order_line.purchase_price_actual
* self.sale_order_line.product_uom_qty
)
self.assertAlmostEqual(
self.sale_order_line.gross_profit,
expected_gross_profit,
msg="Gross profit should be correctly calculated when all stock is reserved.",
)
# Assert gross_profit_percent is calculated correctly
if self.sale_order_line.price_subtotal:
expected_gross_profit_percent = (
self.sale_order_line.gross_profit / self.sale_order_line.price_subtotal
)
else:
expected_gross_profit_percent = 0.0
self.assertAlmostEqual(
self.sale_order_line.gross_profit_percent,
expected_gross_profit_percent,
msg="Gross profit percent should be correctly calculated when all stock is reserved.",
)
# Ensure the SO is correct as well
self.assertSaleOrderComputesLikeSaleOrderLine()

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record model="ir.ui.view" id="sale_margin_sale_order_inherit">
<field name="name">sale.order.margin.view.form.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order"/>
<field name="arch" type="xml">
<field name="margin" position="replace">
<field name="margin" invisible="True"/>
<field name="gross_profit" class="oe_inline"/>
</field>
<label for="margin" position="replace">
<label for="gross_profit" groups="base.group_user"/>
</label>
<field name="margin_percent" position="replace">
<field name="margin_percent" invisible="True"/>
<field name="gross_profit_percent" nolabel="1" class="oe_inline"
widget="percentage" groups="base.group_user"/>
</field>
</field>
</record>
<record model="ir.ui.view" id="sale_margin_sale_order_line_inherit">
<field name="name">sale.order.line.margin.view.form.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order_line"/>
<field name="arch" type="xml">
<field name="purchase_price" position="replace">
<field name="purchase_price" invisible="True"/>
<field name="purchase_price_actual" groups="base.group_user"/>
</field>
</field>
</record>
<record model="ir.ui.view" id="sale_margin_sale_order_line_form_inherit">
<field name="name">sale.order.view.form</field>
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order_line_form"/>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<field name="purchase_price" position="replace">
<field name="purchase_price" column_invisible="True"/>
<field name="purchase_price_actual" optional="hide"/>
</field>
<field name="margin" position="replace">
<field name="margin" column_invisible="True"/>
<field name="gross_profit" optional="hide"/>
</field>
<field name="margin_percent" position="replace">
<field name="margin_percent" column_invisible="True"/>
<field name="gross_profit_percent" optional="hide"
widget="percentage" groups="base.group_user"/>
</field>
</field>
</record>
<!-- For now, we don't override the pivot and graph views because our fields
are not stored and this will cause performance issues. -->
</data>
</odoo>

View file

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

View file

@ -0,0 +1,35 @@
{
"name": "Automated Partner Association by Email Domain",
"version": "18.0.0.0.1",
"category": "Extra Tools",
"summary": "Automatically associates partners with companies using matching email domains",
"description": """
This advanced utility module empowers Odoo users with the capacity to automate partner-company associations
based directly on email domains. It helps in eliminating manual effort by establishing a rule of correlation
between the email domain of a partner and the corresponding division or a company.
The automation proceeds as follows:
- On creation of partner records, the system will cross-verify the email domain against the companies
in the records.
- If a match is detected, the partner will automatically be linked with that company.
This module also takes care of instances where there are multiple companies with the same email domain.
In such cases, an email is dispatched to the partner with a selection interface to finalize the association.
The module is a significant productivity enhancer for businesses that deal with a high number of partners
and require efficient management.
""",
"depends": ["base", "mail", "website"],
"data": [
"views/res_partner_views.xml",
"data/mail_template_data.xml",
"data/template_data.xml",
],
"license": "AGPL-3",
"author": "Bemade",
"website": "https://bemade.org/",
"installable": True,
"auto_install": False,
}

View file

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

View file

@ -0,0 +1,40 @@
# Import necessary libraries
from odoo import http, _
#from odoo.http import request
# Define a controller class that inherits from http.Controller
class DivisionCompanyController(http.Controller):
# Decorator to set the routing rules for the method 'select_division_company'.
# The URL for this method will be '/select_division_company'
# It has public access and should be used for HTTP requests of type GET.
@http.route('/select_division_company', website=True, type='http', auth='public')
def select_division_company(self, partner_id, access_token, division_id, **kwargs):
# Search the 'res.partner' model for a record with the given partner_id using Super User access rights
partner = http.request.env['res.partner'].sudo().search([
('id', '=', int(partner_id))
])
# Check if a partner was found with the given ID
if not partner:
# If no partner found, render an error page with a translatable error message
return http.request.render('bemade_partner_email_domain.error_page',
{'error_message': _('The requested partner is not found.')})
else:
# If the partner exists and the access token matches, proceed with updating the partner's division
if access_token and access_token == partner.access_token:
# Update the partner's parent with the division_id
partner.write({'parent_id': int(division_id)})
# Set a confirmation message signaling that the partner has been associated with the division
confirmation_message = _('Partner %s is associated with the division %s.' % (partner.name, division_id))
# Render the error page (which in this case acts as a status page) with the confirmation message.
return http.request.render('bemade_partner_email_domain.error_page',
{'confirmation_message': confirmation_message})
else:
# If the access token was not provided or does not match, render an error page with an error message
return http.request.render('bemade_partner_email_domain.error_page',
{'error_message': _('No Access Token found or not matching for this partner.')})

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data noupdate="1">
<record id="email_template_select_parent" model="mail.template">
<field name="name">Division Selection: Choose Your Division</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="email_from">{{object.company_id.email_formatted or user.email_formatted}}</field>
<field name="partner_to">{{object.id}}</field>
<field name="subject">Select Your Division at {{object.company_id.name}}</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0"
style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial, sans-serif; color: #454748; width: 100%; border-collapse:separate;">
<tbody>
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<!-- Content here -->
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tbody>
<tr>
<td valign="top" style="font-size: 13px;">
<p>Hello <t t-out="object.name or ''">Brandon Freeman</t>,</p>
<p>We noticed that you have registered with our company by sending an email or using our website. To provide you with the best possible service, we need you to confirm the division of your company that you are working for.</p>
<p>Please click on the link below to select your division:</p>
<ul>
<t t-foreach="ctx.get('links', {}).items()" t-as="company_name">
<li>
<a t-attf-href="{{ company_name[1] }}" style="font-size:12px">
<t t-esc="company_name[0]"/>
</a>
</li>
</t>
</ul>
<p>If you have any questions or did not register with us, please disregard this email or contact us directly.</p>
<p>Best regards,</p>
<p>The Durpro team.</p>
</td>
</tr>
<!-- More content can be added here -->
</tbody>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tbody>
<tr>
<td valign="middle" align="left">
<t t-out="object.company_id.name or ''"/>
</td>
</tr>
<tr>
<td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.company_id.phone or ''"/>
<t t-if="object.company_id.email">
| <a t-att-href="'mailto:%s' % object.company_id.email" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''"></a>
</t>
<t t-if="object.company_id.website">
| <a t-att-href="'%s' % object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''"></a>
</t>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<!-- POWERED BY -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tbody>
<tr>
<td style="text-align: center; font-size: 13px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=auth" style="color: #875A7B;">Odoo</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="error_page">
<base href="/" />
<t t-call="website.layout">
<div id="wrap">
<t t-if="error_message">
<div class="alert alert-danger"><t t-esc="error_message"/></div>
</t>
<t t-if="confirmation_message">
<div class="alert alert-success"><t t-esc="confirmation_message"/></div>
</t>
</div>
</t>
</template>
</data>
</odoo>

View file

@ -0,0 +1,41 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * bemade_partner_email_domain
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 15.0\n"
"Report-Msgid-Bugs-To: it@bemade.org\n"
"Last-Translator: chatgpt\n"
"Language-Team: French (http://www.transifex.com/odoo/odoo-14-0/language/fr_CA/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#: model:ir.model.fields,field_description:bemade_partner_email_domain.field_res_partner_access_token
msgid "Access Token"
msgstr "Jeton d'accès"
#: model:ir.model.fields,field_description:bemade_partner_email_domain.field_res_partner_email_domain
msgid "Email Domain"
msgstr "Domaine de courriel"
#: model:ir.model.fields,field_description:bemade_partner_email_domain.field_res_partner_is_subdivision
msgid "Subdivision"
msgstr "Subdivision"
#: code:addons/bemade_partner_email_domain/controllers/main.py:0
#, python-format
msgid "The requested partner is not found."
msgstr "Le partenaire demandé n'est pas trouvé."
#: code:addons/bemade_partner_email_domain/controllers/main.py:0
#, python-format
msgid "No Access Token found or not matching for this partner."
msgstr "Aucun jeton d'accès trouvé ou ne correspondant pas à ce partenaire."
#: code:addons/bemade_partner_email_domain/controllers/main.py:0
#, python-format
msgid "Partner %s is associated with the division %s."
msgstr "Le partenaire %s est associé à la division %s."

View file

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

View file

@ -0,0 +1,97 @@
from odoo import api, fields, models, Command
from odoo.tools.sql import SQL
import uuid
from odoo.addons.website.controllers.main import Website
class Partner(models.Model):
_inherit = "res.partner"
email_domain = fields.Char(string="Email Domain")
is_subdivision = fields.Boolean(string="Subdivision", default=False)
access_token = fields.Char(string="Access Token")
def _generate_access_token(self):
return uuid.uuid4().hex
def _send_selection_email(self, division_companies):
self.ensure_one()
# Generate a token and save it to the partner
access_token = self._generate_access_token()
print(f"partner id: {self.id}")
self.write({"access_token": access_token})
# Now include the token in the links
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
links = {
company.name: (
f"{base_url}/select_division_company?partner_id={self.id}&access_token={access_token}&division_id={company.id}"
)
for company in division_companies
}
print(f"links: {links}")
# Render the email template and send the email
template = self.env.ref(
"bemade_partner_email_domain.email_template_select_parent"
)
template.with_context(links=links).send_mail(self.id, force_send=True)
def _check_parent_from_email_domain(self):
for rec in self:
if rec.parent_id:
continue
try:
# Split the email address on '@' and get the domain part
if rec.email:
email_domain = rec.email.split("@")[1].strip()
else:
continue
except IndexError:
# If there's no '@' symbol, the email address is invalid
return False
sql = """
SELECT id
FROM res_partner
WHERE email_domain IS NOT NULL
AND email_domain = RIGHT(%s, LENGTH(email_domain))
AND company_id = %s
"""
self.env.cr.execute(
SQL(sql, email_domain, self.env.company and self.env.company.id or None)
)
res = self.env.cr.fetchall()
if len(res) > 1:
rec._send_selection_email(
self.env["res.partner"].search(
[("id", "in", [result[0] for result in res])]
)
)
elif len(res) == 1:
rec.write({"parent_id": res[0][0]})
else:
continue
@api.model_create_multi
def create(self, vals_list):
res = super(Partner, self).create(vals_list)
res._check_parent_from_email_domain()
return res
def write(self, vals):
res = super(Partner, self).write(vals)
if "email" in vals:
self._check_parent_from_email_domain()
return res
@api.onchange("is_subdivision")
def _onchange_is_subdivision(self):
"""Change the contact type since partners of type "contact" get their address
overwritten when the parent_id is changed."""
for record in self:
partner = record._origin
if record.is_subdivision and record.type == "contact":
record.type = "other"
partner.type = "other"

View file

@ -0,0 +1,20 @@
<?xml version="1.0"?>
<odoo>
<record id="partner_email_domain_form" model="ir.ui.view">
<field name="name">bemade_partner_email_domain.partner.email.domain.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<field name="company_type" position="after">
<label for="is_subdivision" string="Division" invisible="is_company == False"/>
<field name="is_subdivision" invisible="is_company == False"/>
</field>
<field name="website" position="before">
<field name="email_domain" invisible="is_company == False"/>
</field>
<field name="parent_id" position="attributes">
<attribute name="invisible">is_company == True and is_subdivision == False</attribute>
</field>
</field>
</record>
</odoo>

View file

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

View file

@ -0,0 +1,32 @@
#
# Bemade Inc.
#
# Copyright (C) June 2023 Bemade Inc. (<https://www.bemade.org>).
# Author: mdurepos (Contact : it@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": "Partner Root Ancestor",
"version": "18.0.1.0.0",
"summary": "Technical module to add the field root_ancestor to res.partner.",
"category": "Generic Modules/Base",
"author": "Bemade Inc.",
"website": "https://www.bemade.org",
"license": "OPL-1",
"depends": ["base"],
"data": [],
"demo": [],
"installable": True,
"auto_install": False,
}

View file

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

View file

@ -0,0 +1,18 @@
from odoo import models, fields, api, _, Command
class Partner(models.Model):
_inherit = 'res.partner'
root_ancestor = fields.Many2one(
comodel_name='res.partner',
string='Root Ancestor',
compute='_compute_root_ancestor',
store=True,
recursive=True
)
@api.depends('parent_id', 'parent_id.root_ancestor')
def _compute_root_ancestor(self):
for rec in self:
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or rec

View file

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

View file

@ -0,0 +1,71 @@
from odoo.tests.common import TransactionCase
class TestPartnerRootAncestor(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
partner = cls.env["res.partner"]
cls.common_ancestor = partner.create(
{
"name": "Parnter name",
"company_type": "company",
}
)
cls.first_child = partner.create(
{
"name": "Child name",
"company_type": "company",
"parent_id": cls.common_ancestor.id,
}
)
cls.second_child = partner.create(
{
"name": "Child name",
"company_type": "company",
"parent_id": cls.common_ancestor.id,
}
)
cls.first_grandchild = partner.create(
{
"name": "Grandchild name",
"company_type": "person",
"parent_id": cls.first_child.id,
}
)
cls.second_grandchild = partner.create(
{
"name": "Grandchild name",
"company_type": "person",
"parent_id": cls.second_child.id,
}
)
cls.unrelated_partner = partner.create(
{
"name": "Unrelated partner",
}
)
cls.relatives = [
cls.common_ancestor,
cls.first_child,
cls.second_child,
cls.first_grandchild,
cls.second_grandchild,
]
def test_base_case(self):
for r1 in self.relatives:
self.assertFalse(r1.root_ancestor == self.unrelated_partner)
for r2 in self.relatives:
self.assertTrue(r1.root_ancestor == r2.root_ancestor)
def test_new_relative(self):
new_relative = self.env["res.partner"].create(
{
"name": "Whatever",
"company_type": "person",
"parent_id": self.second_grandchild.id,
}
)
for r1 in self.relatives:
self.assertTrue(r1.root_ancestor == new_relative.root_ancestor)

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