Initial commit: OCA Workflow Process packages (456 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:00 +02:00
commit d366e42934
18799 changed files with 1284507 additions and 0 deletions

View file

@ -0,0 +1,48 @@
# Sale planner calendar
Odoo addon: sale_planner_calendar
## Installation
```bash
pip install odoo-bringout-oca-sale-workflow-sale_planner_calendar
```
## Dependencies
This addon depends on:
- calendar
- sale
- sale_payment_sheet
## Manifest Information
- **Name**: Sale planner calendar
- **Version**: 16.0.3.0.0
- **Category**: Sale
- **License**: AGPL-3
- **Installable**: True
## Source
Based on [OCA/sale-workflow](https://github.com/OCA/sale-workflow) branch 16.0, addon `sale_planner_calendar`.
## License
This package maintains the original AGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Sale_planner_calendar Module - sale_planner_calendar
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for sale_planner_calendar. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,7 @@
# Dependencies
This addon depends on:
- [calendar](../../odoo-bringout-oca-ocb-calendar)
- [sale](../../odoo-bringout-oca-ocb-sale)
- [sale_payment_sheet](../../odoo-bringout-oca-sale-workflow-sale_payment_sheet)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon sale_planner_calendar or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-sale-workflow-sale_planner_calendar"
# or
uv pip install odoo-bringout-oca-sale-workflow-sale_planner_calendar"
```

View file

@ -0,0 +1,23 @@
# Models
Detected core models and extensions in sale_planner_calendar.
```mermaid
classDiagram
class sale_planner_calendar_issue_type
class sale_planner_calendar_summary
class calendar_attendee
class calendar_event
class calendar_event_type
class mail_thread
class res_company
class res_config_settings
class res_partner
class res_users
class sale_order
class sale_payment_sheet_line
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: sale_planner_calendar. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon sale_planner_calendar
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,41 @@
# Security
Access control and security definitions in sale_planner_calendar.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../sale_planner_calendar/security/ir.model.access.csv)**
- 5 model access rules
## Record Rules
Row-level security rules defined in:
## Security Groups & Configuration
Security groups and permissions defined in:
- **[sale_planner_calendar_security.xml](../sale_planner_calendar/security/sale_planner_calendar_security.xml)**
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[ir.model.access.csv](../sale_planner_calendar/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
- **[sale_planner_calendar_security.xml](../sale_planner_calendar/security/sale_planner_calendar_security.xml)**
- Security groups, categories, and XML-based rules
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon sale_planner_calendar
```

View file

@ -0,0 +1,11 @@
# Wizards
Transient models exposed as UI wizards in sale_planner_calendar.
```mermaid
classDiagram
class SaleInvoicePaymentWiz
class SalePlannerCalendarReassignLineWiz
class SalePlannerCalendarReassignWiz
class SalePlannerCalendarWizard
```

View file

@ -0,0 +1,44 @@
[project]
name = "odoo-bringout-oca-sale-workflow-sale_planner_calendar"
version = "16.0.0"
description = "Sale planner calendar - Sale planner calendar"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-calendar>=16.0.0",
"odoo-bringout-oca-ocb-sale>=16.0.0",
"odoo-bringout-oca-sale-workflow-sale_payment_sheet>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["sale_planner_calendar"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]

View file

@ -0,0 +1,138 @@
=====================
Sale planner calendar
=====================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:9536f0a762b2909e0b503ba68e80f5fbd7d358124a934194fbc5d67b657e4510
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github
:target: https://github.com/OCA/sale-workflow/tree/16.0/sale_planner_calendar
:alt: OCA/sale-workflow
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_planner_calendar
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module allows to manage commercial visits to partners by using recurrence events.
**Table of contents**
.. contents::
:local:
Configuration
=============
You can find the following configurations in the settings:
#. **Subscriptions Backward Days**: Backward days to search documents to update
subscriptions.
#. **Sale Planner Forward Months**: Forward months to create calendar events.
#. **Send invitation to attendees**: Send invitations to attendees when a planner event
is created.
#. **Sale planner order cut hour**: Time of the next day until which orders of the
current day are assigned.
#. **Calendar event max duration**: Show a warning message when duration is more than
this time. Set 00:00 to disable warning.
Other setting are available with system parameters
#. **Sale order partner** when a so is created from a event planned. You can create or
update the system parameter **sale_planner_calendar.create_so_to_commercial_partner**
with True value to create the sale order to commercial partner instead of partner
Usage
=====
You can create now the recurrent events directly from the partners by clicking next
smart button:
.. image:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_planner_calendar/static/img/smart_button.png
By default the end of the recurrence is set by the settings field
**Sale Planner Forward Months**.
You can manage this new recurrent events from *Calendar planner* menu entry.
.. image:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_planner_calendar/static/img/menu_entry.png
On the first window, you will find a summary of the events that the user has to do
today.
By going to *Calendar planner > Calendar events* you will have two options:
#. View the calendar events related to your user (*My Calendar*).
#. View your base events related to the recurrences (*Recurrent calendar events*).
Finally on *Calendar planner > Wizards* you will have again two options:
#. You can change the hour of the start of all the events related to a recurrency by using
*Sale planner calendar wizard*.
#. You can change the salesperson assigned to events related to a period of time by using
the wizard *Reassignment of salesperson*.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/sale-workflow/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/sale-workflow/issues/new?body=module:%20sale_planner_calendar%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Tecnativa
Contributors
~~~~~~~~~~~~
* `Tecnativa <https://www.tecnativa.com>`__:
* Sergio Teruel
* Carlos Dauden
* Carlos Roca
* Pilar Vargas
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/sale-workflow <https://github.com/OCA/sale-workflow/tree/16.0/sale_planner_calendar>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import models
from . import wizard

View file

@ -0,0 +1,42 @@
# Copyright 2020 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale planner calendar",
"summary": "Sale planner calendar",
"version": "16.0.3.0.0",
"development_status": "Beta",
"category": "Sale",
"website": "https://github.com/OCA/sale-workflow",
"author": "Tecnativa, Odoo Community Association (OCA)",
"license": "AGPL-3",
"application": False,
"installable": True,
"depends": ["calendar", "sale", "sale_payment_sheet"],
"data": [
"security/ir.model.access.csv",
"security/sale_planner_calendar_security.xml",
"data/sale_planner_calendar_action_server.xml",
"data/sale_planner_calendar_cron.xml",
"data/sale_planner_calendar_data.xml",
"views/calendar_event_type_view.xml",
"views/calendar_view.xml",
"views/res_config_settings_views.xml",
"views/res_partner_view.xml",
"views/sale_planner_calendar_event_view.xml",
"views/sale_planner_calendar_issue_type_view.xml",
"views/sale_planner_calendar_summary_view.xml",
"wizard/sale_planner_calendar_reassign.xml",
"wizard/sale_planner_calendar_wizard.xml",
# Menu position fixed
"views/sale_planner_calendar_menu.xml",
],
"assets": {
"web.assets_backend": [
"sale_planner_calendar/static/src/xml/categ_icons_widget_template.xml",
"sale_planner_calendar/static/src/xml/sale_planner_calendar_event_sales.xml",
"sale_planner_calendar/static/src/xml/activity_menu_view.xml",
"sale_planner_calendar/static/src/scss/sale_planner_calendar.scss",
"sale_planner_calendar/static/src/js/*.js",
],
},
}

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<!--
If wizard record is NewId the changes in calendar_event_ids are not saved.
To fix this, we create the wizard first and open later.
-->
<record id="sale_planner_calendar_wizard_action" model="ir.actions.server">
<field name="name">Sale planner calendar wizard</field>
<field
name="model_id"
ref="sale_planner_calendar.model_sale_planner_calendar_wizard"
/>
<field name="state">code</field>
<field name="code">
wiz = model.create({})
action = wiz.get_formview_action()
action['context']['form_view_initial_mode'] = 'edit'
action['context']['control_panel_hidden'] = True
</field>
</record>
<record id="action_set_planner_calendar_event" model="ir.actions.server">
<field name="name">Set planner calendar event</field>
<field name="model_id" ref="sale.model_sale_order" />
<field name="binding_model_id" ref="sale.model_sale_order" />
<field name="state">code</field>
<field name="code">
records.action_set_planner_calendar_event()
</field>
</record>
</odoo>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record
forcecreate="True"
id="sale_planner_calendar_cron_unsubscribe"
model="ir.cron"
>
<field name="name">Sale planner calendar: Unsubscribe documents</field>
<field name="model_id" ref="model_sale_planner_calendar_reassign_wiz" />
<field name="state">code</field>
<field name="code">model.cron_unsubscribe()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True" />
<field name="doall" eval="False" />
</record>
<record forcecreate="True" id="cron_update_dynamic_final_date" model="ir.cron">
<field
name="name"
>Sale planner calendar: Update dynamic event final date</field>
<field name="model_id" ref="model_calendar_event" />
<field name="state">code</field>
<field name="code">model.cron_update_dynamic_final_date()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">months</field>
<field name="numbercall">-1</field>
<field name="active" eval="True" />
<field name="doall" eval="False" />
</record>
</odoo>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="event_type_commercial_visit" model="calendar.event.type">
<field name="name">Commercial visit</field>
<field name="duration">0.25</field>
<field name="icon">fa-car</field>
</record>
<record id="event_type_remote_selling" model="calendar.event.type">
<field name="name">Remote selling</field>
<field name="duration">0.25</field>
<field name="icon">fa-phone</field>
</record>
<record id="event_type_delivery" model="calendar.event.type">
<field name="name">Delivery</field>
<field name="duration">0.25</field>
<field name="icon">fa-truck</field>
</record>
<record id="event_type_message" model="calendar.event.type">
<field name="name">Message</field>
<field name="duration">0.25</field>
<field name="icon">fa-envelope</field>
</record>
<!-- Event planner issue types -->
<record
id="event_planner_issue_type_missing"
model="sale.planner.calendar.issue.type"
>
<field name="name">Missing</field>
</record>
<record
id="event_planner_issue_type_close"
model="sale.planner.calendar.issue.type"
>
<field name="name">Closed</field>
</record>
<record id="config_parameter_partner_name_field" model="ir.config_parameter">
<field name="key">sale_planner_calendar.partner_name_field</field>
<field name="value">name</field>
</record>
<record id="config_parameter_max_duration" model="ir.config_parameter">
<field name="key">sale_planner_calendar.max_duration</field>
<field name="value">4.0</field>
</record>
</odoo>

View file

@ -0,0 +1,110 @@
# Copyright 2024 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from openupgradelib import openupgrade
from psycopg2.extensions import AsIs
def _sale_planner_calendar_event_to_calendar_event(env):
"""Move all data from sale_planner_calendar_event to code model calendar_event"""
sql = """
UPDATE calendar_event SET
sale_planner_state = spce.state,
calendar_issue_type_id = spce.calendar_issue_type_id,
calendar_event_profile_id = spce.calendar_event_profile_id,
comment = spce.comment,
calendar_summary_id = spce.calendar_summary_id,
off_planning = spce.off_planning
FROM sale_planner_calendar_event spce
WHERE spce.calendar_event_id = calendar_event.id
"""
openupgrade.logged_query(env.cr, sql)
def _sale_order_to_calendar_event(env):
"""Link sale order to calendar events instead of sale planner calendar event model"""
# drop_constraint(env.cr, 'sale_order', 'sale_order_sale_planner_calendar_event_id_fkey')
openupgrade.logged_query(
env.cr,
"""
UPDATE sale_order SET
sale_planner_calendar_event_id = spce.calendar_event_id
FROM sale_planner_calendar_event spce
WHERE sale_order.%(old_column)s = spce.id
""",
{
"old_column": AsIs(
openupgrade.get_legacy_name("sale_planner_calendar_event_id")
)
},
)
def _payment_sheet_to_calendar_event(env):
"""Link sale payment sheet to calendar events instead of sale planner calendar
event model
"""
# drop_constraint(env.cr, 'sale_payment_sheet_line',
# 'sale_payment_sheet_line_sale_planner_calendar_event_id_fkey')
openupgrade.logged_query(
env.cr,
"""
UPDATE sale_payment_sheet_line SET
sale_planner_calendar_event_id = spce.calendar_event_id
FROM sale_planner_calendar_event spce
WHERE sale_payment_sheet_line.%(old_column)s = spce.id
""",
{
"old_column": AsIs(
openupgrade.get_legacy_name("sale_planner_calendar_event_id")
)
},
)
def _profiles_to_calendar_event_type(env):
openupgrade.logged_query(
env.cr,
"""
INSERT INTO calendar_event_type (name, icon, old_sale_planner_profile_id)
SELECT name, icon, id FROM sale_planner_calendar_event_profile
""",
)
# Update event linked to profiles
openupgrade.logged_query(
env.cr,
"""
INSERT INTO meeting_category_rel (event_id, type_id)
SELECT ce.id, cet.id
FROM calendar_event ce
JOIN calendar_event_type cet
ON cet.old_sale_planner_profile_id = ce.calendar_event_profile_id
""",
)
def _remove_renamed_selection_values(env):
"""After rename value from upper to lower the values are duplicated.
This method removes old upper values"""
week_list_field = env.ref(
"sale_planner_calendar.field_sale_planner_calendar_wizard__week_list"
)
value_ids = week_list_field.selection_ids.filtered(lambda x: x.value.isupper()).ids
if value_ids:
openupgrade.logged_query(
env.cr,
"""
DELETE FROM ir_model_fields_selection
WHERE id in %(selection_value_ids)s
""",
{"selection_value_ids": tuple(value_ids)},
)
@openupgrade.migrate()
def migrate(env, version):
_sale_planner_calendar_event_to_calendar_event(env)
_sale_order_to_calendar_event(env)
_payment_sheet_to_calendar_event(env)
_profiles_to_calendar_event_type(env)
_remove_renamed_selection_values(env)

View file

@ -0,0 +1,37 @@
# Copyright 2024 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from openupgradelib import openupgrade
_column_renames = {
"sale_order": [
("sale_planner_calendar_event_id", None),
],
"sale_payment_sheet_line": [("sale_planner_calendar_event_id", None)],
}
def _remove_selection_field_values(env):
sql = """
DELETE FROM ir_model_fields_selection
WHERE field_id IN
(SELECT id
FROM ir_model_fields
WHERE ttype='selection' AND model='sale.planner.calendar.event')
"""
openupgrade.logged_query(env.cr, sql)
def _add_event_profile_helper_column(env):
openupgrade.logged_query(
env.cr,
"""
ALTER TABLE calendar_event_type
ADD COLUMN old_sale_planner_profile_id integer""",
)
@openupgrade.migrate()
def migrate(env, version):
openupgrade.rename_columns(env.cr, _column_renames)
_remove_selection_field_values(env)
_add_event_profile_helper_column(env)

View file

@ -0,0 +1,11 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import calendar_event
from . import calendar_event_type
from . import calendar_attendee
from . import res_config_settings
from . import res_partner
from . import res_users
from . import sale_order
from . import sale_payment_sheet
from . import sale_planner_calendar_issue_type
from . import sale_planner_calendar_summary

View file

@ -0,0 +1,20 @@
# Copyright 2024 Tecnativa - Sergio Teruel
# Copyright 2024 Tecnativa - Carlos Dauden
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, models
class CalendarAttendee(models.Model):
_inherit = "calendar.attendee"
@api.model_create_multi
def create(self, vals_list):
if not self.env.company.sale_planner_mail_to_attendees:
new_vals_list = []
for vals in vals_list:
event = self.env["calendar.event"].browse(vals["event_id"])
if not event.target_partner_id:
new_vals_list.append(vals)
vals_list = new_vals_list
return super().create(vals_list)

View file

@ -0,0 +1,435 @@
# Copyright 2021-2024 Tecnativa - Sergio Teruel
# Copyright 2021-2024 Tecnativa - Carlos Dauden
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import re
from datetime import timedelta
import pytz
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.tools.safe_eval import safe_eval
class CalendarEvent(models.Model):
_inherit = "calendar.event"
target_partner_id = fields.Many2one(
comodel_name="res.partner",
string="Sale planner partner",
help="Is the partner used in planner",
)
hour = fields.Float(compute="_compute_hour", inverse="_inverse_hour")
target_partner_mobile = fields.Char(related="target_partner_id.mobile")
# When arrive this date we will unsubscribe user from partner documents
unsubscribe_date = fields.Date()
is_dynamic_end_date = fields.Boolean(copy=False)
# dynamic_end_date = fields.Date(compute="_compute_dynamic_end_date")
advanced_cycle = fields.Boolean()
cycle_number = fields.Integer()
cycle_skip = fields.Integer()
# Field to know which event creates the recurrence
is_base_recurrent_event = fields.Boolean(
compute="_compute_is_base_recurrent_event", store=True
)
sale_planner_currency_id = fields.Many2one(
comodel_name="res.currency",
related="target_partner_id.currency_id",
)
sale_ids = fields.One2many(
comodel_name="sale.order",
inverse_name="sale_planner_calendar_event_id",
)
sale_planner_state = fields.Selection(
[
("pending", "Pending"),
("done", "Done"),
("cancel", "Cancelled"),
],
default="pending",
readonly=True,
tracking=True,
)
calendar_issue_type_id = fields.Many2one(
comodel_name="sale.planner.calendar.issue.type", ondelete="restrict"
)
comment = fields.Text()
sale_order_subtotal = fields.Monetary(
compute="_compute_sale_order_subtotal",
currency_field="sale_planner_currency_id",
)
calendar_summary_id = fields.Many2one(
comodel_name="sale.planner.calendar.summary",
copy=False,
)
invoice_amount_residual = fields.Monetary(
string="Invoice amount due",
compute="_compute_invoice_amount_residual",
compute_sudo=True,
currency_field="sale_planner_currency_id",
)
off_planning = fields.Boolean(copy=False)
payment_sheet_line_ids = fields.One2many(
comodel_name="sale.payment.sheet.line",
inverse_name="sale_planner_calendar_event_id",
)
# Helper fields for kanban views
partner_ref = fields.Char(related="target_partner_id.ref")
partner_name = fields.Char(compute="_compute_partner_name")
partner_commercial_name = fields.Char(
string="Commercial partner name",
related="target_partner_id.commercial_partner_id.name",
)
partner_street = fields.Char(related="target_partner_id.street")
partner_mobile = fields.Char(compute="_compute_contact")
partner_contact_name = fields.Char(compute="_compute_contact")
partner_city = fields.Char(related="target_partner_id.city")
partner_user_id = fields.Many2one(
related="target_partner_id.user_id", string="Partner salesperson"
)
sanitized_partner_mobile = fields.Char(compute="_compute_sanitized_partner_mobile")
location_url = fields.Char(compute="_compute_location_url")
categ_icons = fields.Char(compute="_compute_categ_icons")
# Adding code \uFE0E to force monochrome emoji. Not supported with 🙁 and 🙂 emojis
sale_planner_rating = fields.Selection(
[
("1", "😞\uFE0E"),
("3", "😐\uFE0E"),
("5", "😊\uFE0E"),
],
)
@api.depends("recurrence_id", "recurrence_id.calendar_event_ids")
def _compute_is_base_recurrent_event(self):
for record in self:
record.is_base_recurrent_event = (
record == record.recurrence_id.calendar_event_ids.sorted("start")[:1]
)
@api.depends("start")
def _compute_hour(self):
for rec in self:
date = rec._get_hour_tz_offset()
rec.hour = date.hour + date.minute / 60
@api.depends("sale_ids.amount_untaxed")
def _compute_sale_order_subtotal(self):
for rec in self:
rec.sale_order_subtotal = sum(rec.mapped("sale_ids.amount_untaxed"))
@api.depends("target_partner_id")
def _compute_invoice_amount_residual(self):
partner_ids = self.mapped("target_partner_id.commercial_partner_id").ids
groups = self.env["account.move"]._read_group(
domain=[
("state", "=", "posted"),
("payment_state", "!=", "paid"),
("partner_id", "in", partner_ids),
],
fields=["amount_residual_signed"],
groupby=["partner_id"],
)
invoice_dic = {g["partner_id"][0]: g["amount_residual_signed"] for g in groups}
for rec in self:
amount_residual = invoice_dic.get(
rec.target_partner_id.commercial_partner_id.id, 0.0
)
payment_amount = sum(
rec.payment_sheet_line_ids.filtered(
lambda p: p.sheet_id.state == "open"
).mapped("amount")
)
rec.invoice_amount_residual = amount_residual - payment_amount
@api.depends("target_partner_id")
def _compute_partner_name(self):
field_name = (
self.env["ir.config_parameter"]
.sudo()
.get_param(
"sale_planner_calendar.partner_name_field",
default="name",
)
)
for event in self:
# If more flexibility is needed use event.mapped(field_name)[0]
event.partner_name = (
event.target_partner_id[field_name]
or event.target_partner_id.name
or event.target_partner_id.commercial_partner_id.name
)
@api.depends("target_partner_id")
def _compute_contact(self):
for rec in self:
contact = rec.target_partner_id.child_ids.filtered(
"is_sale_planner_contact"
)[:1]
rec.partner_mobile = (contact.mobile or contact.phone) or (
rec.target_partner_id.mobile or rec.target_partner_id.phone
)
rec.partner_contact_name = contact.name
@api.depends("partner_mobile")
def _compute_sanitized_partner_mobile(self):
self.sanitized_partner_mobile = False
for rec in self.filtered("partner_mobile"):
rec.sanitized_partner_mobile = re.sub(r"\W+", "", rec.partner_mobile)
@api.depends("target_partner_id")
def _compute_location_url(self):
# The url is built to access the location from a google link. This will be done
# taking into account the location of the calendar event associated with the calendar
# planner event. If this location is not defined, the client's coordinates will
# be taken into account if they are defined, otherwise the client's address
# will be taken into account.
self.location_url = False
for event in self:
event_location = event.location
partner_latitude = str(event.target_partner_id.partner_latitude).replace(
",", "."
)
partner_longitude = str(event.target_partner_id.partner_longitude).replace(
",", "."
)
partner_location = f"{event.partner_city}+{event.partner_street}"
if event_location:
event.location_url = event_location.replace(" ", "+")
elif partner_latitude != "0.0" or partner_longitude != "0.0":
event.location_url = f"{partner_latitude}%2C{partner_longitude}"
elif partner_location:
event.location_url = partner_location.replace(" ", "+")
@api.depends("categ_ids")
def _compute_categ_icons(self):
for event in self:
categ_icons_list = []
for categ in event.categ_ids.filtered("icon"):
# Avoid repeat same icon
if categ.icon not in categ_icons_list:
categ_icons_list.append(categ.icon)
event.categ_icons = ",".join(categ_icons_list)
def _inverse_hour(self):
for rec in self:
duration = rec.duration
date = self._get_hour_tz_offset()
new_time = date.replace(
hour=int(rec.hour), minute=int(round((rec.hour % 1) * 60))
)
# Force to onchange get correct value
new_time = new_time.astimezone(pytz.utc).replace(tzinfo=None)
rec.write(
{
"recurrence_update": "all_events",
"start": new_time,
"stop": new_time + timedelta(minutes=round((duration or 0.5) * 60)),
}
)
@api.onchange("start", "stop", "duration")
def _onchange_duration(self):
"""Show warning if duration more than max duration set in config."""
if not self.target_partner_id:
return
max_duration = float(
self.env["ir.config_parameter"]
.sudo()
.get_param(
"sale_planner_calendar.max_duration",
default="0.0",
)
)
if max_duration and self.duration > max_duration:
return {
"warning": {
"title": "Max duration exceeded",
"message": "Max duration set in config parameters is {} hours".format(
max_duration
),
"type": "notification",
}
}
@api.onchange("categ_ids")
def _onchange_categ_ids(self):
if not self.name and self.categ_ids:
self.name = self.categ_ids[:1].name
# Clean name if is equal to stored categ name and this categ is removed
elif not self.categ_ids and self.name == self._origin.categ_ids[:1].name:
self.name = False
def action_open_sale_order(self, new_order=False):
"""
Search or Create an event planner linked to sale order
"""
action_xml_id = (
self.env["ir.config_parameter"]
.sudo()
.get_param(
"sale_planner_calendar.action_open_sale_order",
"sale.action_quotations_with_onboarding",
)
)
action = self.env["ir.actions.act_window"]._for_xml_id(action_xml_id)
if new_order:
action["name"] = "New Quotation"
action["context"] = self.env.context
return action
# Create sale order to planner partner or commercial partner depending of the
# system parameter
create_so_to_commercial_partner = (
self.env["ir.config_parameter"]
.sudo()
.get_param("sale_planner_calendar.create_so_to_commercial_partner", "False")
)
partner = (
self.target_partner_id
if create_so_to_commercial_partner == "False"
else self.target_partner_id.commercial_partner_id
)
action["context"] = {
"default_sale_planner_calendar_event_id": self.id,
"default_partner_id": partner.id,
"default_partner_shipping_id": self.target_partner_id.id,
"default_user_id": self.user_id.id,
}
if len(self.sale_ids) > 1:
action["domain"] = [("sale_planner_calendar_event_id", "=", self.id)]
else:
action["views"] = list(filter(lambda v: v[1] == "form", action["views"]))
action["res_id"] = self.sale_ids.id
return action
def action_open_invoices(self):
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_payment_sheet.action_invoice_sale_payment_sheet"
)
ctx = safe_eval(action["context"])
ctx.update(
{
"default_partner_id": self.target_partner_id.id,
}
)
action["context"] = ctx
domain = safe_eval(action["domain"])
domain.append(
("partner_id", "=", self.target_partner_id.commercial_partner_id.id),
)
action["domain"] = domain
return action
def action_open_unpaid_invoice(self):
domain = [
("state", "=", "posted"),
("move_type", "in", ["out_invoice", "out_refund"]),
("partner_id", "=", self.target_partner_id.commercial_partner_id.id),
("payment_state", "!=", "paid"),
]
unpaid_invoices = self.env["account.move"].search(domain)
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_payment_sheet.action_sale_invoice_payment_wiz"
)
ctx = safe_eval(action["context"])
ctx.update(
{
"invoice_ids": unpaid_invoices.ids,
"default_sale_planner_calendar_event_id": self.id,
"default_partner_id": self.target_partner_id.id,
}
)
action["context"] = ctx
return action
def action_done(self):
self.write(
{
"sale_planner_state": "done",
}
)
def action_cancel(self):
self.write(
{
"sale_planner_state": "cancel",
"comment": "Not done",
}
)
def action_pending(self):
self.write(
{
"sale_planner_state": "pending",
"comment": False,
"sale_planner_rating": False,
}
)
def action_open_issue(self):
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_planner_calendar.action_sale_planner_calendar_issue"
)
action["res_id"] = self.id
return action
def action_apply_issue(self):
pass
def action_open_rating(self):
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_planner_calendar.action_sale_planner_calendar_rating"
)
action["res_id"] = self.id
return action
def action_set_sale_planner_rating(self):
self.action_done()
def _get_hour_tz_offset(self):
timezone = self._context.get("tz") or self.env.user.partner_id.tz or "UTC"
self_tz = self.with_context(tz=timezone)
date = fields.Datetime.context_timestamp(self_tz, self.start)
return date
def get_week_days_count(self):
days = ["mo", "tu", "we", "th", "fr", "sa"]
days_count = 0
for day in days:
if self[day]:
days_count += 1
return days_count
def _get_recurrent_dates_by_event(self):
dates_list = super()._get_recurrent_dates_by_event()
if not self.advanced_cycle:
return dates_list
new_dates = []
index = 0
skip_count = 0
cycle_number = self.cycle_number
cycle_skip = self.cycle_skip
if self.rrule_type == "weekly":
days_count = self.get_week_days_count()
cycle_number *= days_count
cycle_skip *= days_count
for dates in dates_list:
if index < cycle_number:
new_dates.append(dates)
index += 1
else:
skip_count += 1
if skip_count >= cycle_skip:
index = 0
skip_count = 0
return new_dates
@api.model
def cron_update_dynamic_final_date(self):
events_to_update = self.search(
[("is_dynamic_end_date", "=", True), ("is_base_recurrent_event", "=", True)]
)
new_date = fields.Date.today() + relativedelta(
months=self.env.company.sale_planner_forward_months, day=31
)
events_to_update.until = new_date

View file

@ -0,0 +1,12 @@
# Copyright 2021-2024 Tecnativa - Sergio Teruel
# Copyright 2021-2024 Tecnativa - Carlos Dauden
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CalendarEventType(models.Model):
_inherit = "calendar.event.type"
duration = fields.Float()
icon = fields.Char(help="Font awesome icon e.g. fa-tasks")

View file

@ -0,0 +1,45 @@
# Copyright 2020 Sergio Teruel - Tecnativa
# Copyright 2021 Tecnativa - Carlos Dauden
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
susbscriptions_backward_days = fields.Integer(
related="company_id.susbscriptions_backward_days",
readonly=False,
)
sale_planner_forward_months = fields.Integer(
related="company_id.sale_planner_forward_months",
readonly=False,
)
sale_planner_mail_to_attendees = fields.Boolean(
related="company_id.sale_planner_mail_to_attendees",
readonly=False,
)
sale_planner_order_cut_hour = fields.Float(
related="company_id.sale_planner_order_cut_hour",
readonly=False,
)
sale_planner_calendar_max_duration = fields.Float(
string="Calendar event max duration",
config_parameter="sale_planner_calendar.max_duration",
)
class ResCompany(models.Model):
_inherit = "res.company"
susbscriptions_backward_days = fields.Integer(
default=180,
)
sale_planner_forward_months = fields.Integer(
default=12,
)
sale_planner_mail_to_attendees = fields.Boolean(
string="Send invitation to attendees", default=True
)
sale_planner_order_cut_hour = fields.Float()

View file

@ -0,0 +1,90 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import _, fields, models
from odoo.exceptions import ValidationError
class ResPartner(models.Model):
_inherit = "res.partner"
is_sale_planner_contact = fields.Boolean()
def action_calendar_planner(self):
categ = self.env.ref("sale_planner_calendar.event_type_commercial_visit")
action = self.env["ir.actions.act_window"]._for_xml_id(
"calendar.action_calendar_event"
)
sale_planner_forward_months = self.env.company.sale_planner_forward_months
# TODO: Get default values from res.config.settings
action["context"] = {
"default_target_partner_id": self.id,
"default_categ_ids": [(4, categ.id)],
# Passing True omits the partner name, ensuring precise calculation of GPS location.
"default_location": self._display_address(True).replace("\n", " "),
"default_duration": categ.duration,
"default_name": categ.name,
"default_start": fields.Datetime.now(),
"default_stop": fields.Datetime.now()
+ timedelta(minutes=round((categ.duration or 1.0) * 60)),
"default_recurrency": True,
"default_rrule_type": "weekly",
"default_end_type": "end_date",
"default_until": fields.Date.today()
+ relativedelta(months=sale_planner_forward_months),
"default_is_dynamic_end_date": True,
"default_user_id": self.user_id.id or self.env.user.id,
"default_partner_ids": [
(
6,
0,
[
self.id,
self.user_id.partner_id.id or self.env.user.partner_id.id,
],
)
],
"choose_unlink_method": True,
}
if not self.env.company.sale_planner_mail_to_attendees:
action["context"].update(
{
"no_mail_to_attendees": True,
"dont_notify": True,
}
)
action["view_mode"] = "tree,form"
action["view_id"] = False
action["views"] = []
action["domain"] = [
("target_partner_id", "=", self.id),
("recurrency", "=", True),
("recurrence_id.until", ">", fields.Date.today()),
("is_base_recurrent_event", "=", True),
]
return action
def write(self, vals):
if (
"user_id" in vals
and vals.get("user_id")
and not self.env.context.get("skip_sale_planner_check", False)
):
calendar_events = self.env["calendar.event"].search(
[
("target_partner_id", "in", self.ids),
("recurrency", "!=", False),
("user_id", "!=", vals["user_id"]),
("is_base_recurrent_event", "=", True),
]
)
if calendar_events:
msg = _(
"This partner has sale planned events\n"
"You must change salesperson from the planner wizard"
)
raise ValidationError(msg)
return super().write(vals)

View file

@ -0,0 +1,73 @@
# Copyright 2024 Tecnativa - Carlos Roca
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
from datetime import datetime
import pytz
from odoo import _, api, fields, models, modules
class ResUsers(models.Model):
_inherit = "res.users"
def _get_sale_planner_calendar_events_domain(self):
return [
("user_id", "=", self.env.user.id),
("target_partner_id", "!=", False),
("sale_planner_state", "=", "pending"),
]
@api.model
def get_action_sale_planner_calendar_event(self):
return self.env["ir.actions.act_window"]._for_xml_id(
"sale_planner_calendar.action_sale_planner_calendar_event"
)
@api.model
def systray_get_activities(self):
res = super().systray_get_activities()
# Get user timezone or UTC if not set
user_tz = pytz.timezone(self.env.user.tz or "UTC")
# Get current time in user's timezone
datetime_now = datetime.now(user_tz)
domain = self._get_sale_planner_calendar_events_domain()
# Start of day in user's timezone
start_date_today_tz = datetime_now.replace(hour=0, minute=0, second=0)
# Convert to UTC for domain
start_date_today_utc = start_date_today_tz.astimezone(pytz.UTC)
# End of day in user's timezone
end_date_today_tz = datetime_now.replace(hour=23, minute=59, second=59)
# Convert to UTC for domain
end_date_today_utc = end_date_today_tz.astimezone(pytz.UTC)
domain_today = domain + [
("start", ">=", fields.Datetime.to_string(start_date_today_utc)),
("start", "<=", fields.Datetime.to_string(end_date_today_utc)),
]
events_today_count = self.env["calendar.event"].search_count(domain_today)
events_overdue_count = self.env["calendar.event"].search_count(
domain + [("start", "<", start_date_today_utc)]
)
events_planned_count = self.env["calendar.event"].search_count(
domain + [("start", ">", end_date_today_utc)]
)
total_count = events_today_count + events_overdue_count
if total_count:
res.append(
{
"id": self.env["ir.model"]._get("calendar.event").id,
"type": "activity",
"name": _("Sale planner calendar events"),
"model": "calendar.event",
"icon": modules.module.get_module_icon(
self.env["calendar.event"]._original_module
),
"total_count": total_count,
"today_count": events_today_count,
"overdue_count": events_overdue_count,
"planned_count": events_planned_count,
"is_planner": True,
"domain": json.dumps(domain),
}
)
return res

View file

@ -0,0 +1,128 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
class SaleOrder(models.Model):
_inherit = "sale.order"
sale_planner_calendar_event_id = fields.Many2one(comodel_name="calendar.event")
def _action_confirm(self):
event_obj = self.env["calendar.event"]
event_type_delivery = self.env.ref("sale_planner_calendar.event_type_delivery")
for order in self:
if not order.expected_date or order.commitment_date:
continue
delivery_event = event_obj.search(
[
("target_partner_id", "=", order.partner_id.id),
("start", ">", fields.Datetime.to_string(order.expected_date)),
("categ_ids", "in", event_type_delivery.ids),
],
order="start",
limit=1,
)
if delivery_event:
order.commitment_date = delivery_event.start
return super()._action_confirm()
def _prepare_calendar_event_planner(self):
categ = self.env.ref("sale_planner_calendar.event_type_commercial_visit")
return {
"name": _("Sale off planning"),
"target_partner_id": self.partner_id.id,
"user_id": self.user_id.id,
"start": self.date_order,
"stop": self.date_order
+ timedelta(minutes=round((categ.duration or 1.0) * 60)),
"off_planning": True,
"sale_planner_state": "done",
}
def action_set_planner_calendar_event(self, planner_summary=False):
orders = self.filtered(lambda so: not so.sale_planner_calendar_event_id)
if not orders:
return
order_dates = orders.mapped("date_order")
if not planner_summary:
planner_summary = self.env["sale.planner.calendar.summary"]
date_from = planner_summary._get_datetime_from_date_tz_hour(
min(order_dates), self.env.company.sale_planner_order_cut_hour
)
date_to = planner_summary._get_datetime_from_date_tz_hour(
max(order_dates), self.env.company.sale_planner_order_cut_hour
) + relativedelta(days=1)
calendar_event_domain = [
("target_partner_id", "in", orders.partner_id.ids),
("user_id", "in", orders.user_id.ids),
("start", ">=", date_from),
("start", "<", date_to),
]
if planner_summary.event_type_id:
calendar_event_domain.append(
("categ_ids", "in", planner_summary.event_type_id.ids)
)
calendar_events = self.env["calendar.event"].search(calendar_event_domain)
planner_summary_domain = [
("user_id", "in", orders.user_id.ids),
("date", ">=", date_from.date()),
("date", "<", date_to.date()),
]
if planner_summary.event_type_id:
planner_summary_domain.append(
("event_type_id", "=", planner_summary.event_type_id.id)
)
event_summaries = planner_summary or self.env[
"sale.planner.calendar.summary"
].search(planner_summary_domain)
if not event_summaries:
return
cut_time = date_from.time()
for order in orders:
event = calendar_events.filtered(
lambda ev: ev.target_partner_id == order.partner_id
and ev.user_id == order.user_id
and (ev.start.combine(ev.start.date(), cut_time) <= order.date_order)
and (
ev.start.combine(ev.start.date(), cut_time) + relativedelta(days=1)
> order.date_order
)
)[:1]
if not event:
event_summary = event_summaries.filtered(
lambda sm: sm.user_id == order.user_id
and (
(
sm.date == order.date_order.date()
and order.date_order.time() >= cut_time
)
or (
sm.date == order.date_order.date() + relativedelta(days=1)
and order.date_order.time() < cut_time
)
)
)
if len(event_summary) > 1:
# Sorted to select first summary without event_type
event_summary = event_summary.sorted("event_type_id")[:1]
if not event_summary:
continue
event_vals = order._prepare_calendar_event_planner()
event_vals["calendar_summary_id"] = event_summary.id
event = self.env["calendar.event"].create(event_vals)
order.sale_planner_calendar_event_id = event
@api.model_create_multi
def create(self, vals_list):
orders = super().create(vals_list)
if self.env.context.get("calendar_summary_id"):
planner_summary = self.env["sale.planner.calendar.summary"].browse(
self.env.context["calendar_summary_id"]
)
orders.action_set_planner_calendar_event(planner_summary=planner_summary)
return orders

View file

@ -0,0 +1,10 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class SalePaymentSheetLine(models.Model):
_inherit = "sale.payment.sheet.line"
sale_planner_calendar_event_id = fields.Many2one(comodel_name="calendar.event")

View file

@ -0,0 +1,11 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class SaleEventPlannerIssueType(models.Model):
_name = "sale.planner.calendar.issue.type"
_description = "Sale planner calendar issue type"
name = fields.Char(translate=True)

View file

@ -0,0 +1,366 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from datetime import timedelta
import pytz
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class SalePlannerCalendarSummary(models.Model):
_name = "sale.planner.calendar.summary"
_description = "Sale planner calendar summary"
_inherit = "mail.thread"
company_id = fields.Many2one(
comodel_name="res.company", default=lambda self: self.env.company.id
)
currency_id = fields.Many2one(
comodel_name="res.currency",
related="company_id.currency_id",
)
date = fields.Date(default=fields.Date.context_today)
sale_planner_calendar_event_ids = fields.One2many(
comodel_name="calendar.event",
inverse_name="calendar_summary_id",
)
user_id = fields.Many2one(
comodel_name="res.users",
default=lambda self: self.env.user.id,
index=True,
domain="[('share','=',False)]",
)
sale_ids = fields.One2many(
comodel_name="sale.order",
compute="_compute_sale_ids",
)
state = fields.Selection(
[
("pending", "Pending"),
("done", "Done"),
("cancel", "Cancelled"),
],
default="pending",
)
comment = fields.Text()
sale_order_subtotal = fields.Monetary(
compute="_compute_sale_ids", currency_field="currency_id"
)
event_type_id = fields.Many2one(
comodel_name="calendar.event.type",
string="Event type",
)
# Summary data results
event_total_count = fields.Integer(compute="_compute_event_planner_count")
event_done_count = fields.Integer(compute="_compute_event_planner_count")
event_effective_count = fields.Integer(compute="_compute_event_planner_count")
event_off_planning_count = fields.Integer(compute="_compute_event_planner_count")
sale_order_count = fields.Integer(compute="_compute_sale_ids")
payment_count = fields.Integer(compute="_compute_event_planner_count")
payment_amount = fields.Monetary(compute="_compute_event_planner_count")
@api.depends("sale_planner_calendar_event_ids")
def _compute_sale_ids(self):
for rec in self:
sales = rec.sale_planner_calendar_event_ids.mapped("sale_ids")
rec.sale_ids = sales
rec.sale_order_subtotal = sum(sales.mapped("amount_untaxed"))
rec.sale_order_count = len(sales)
@api.depends(
"sale_planner_calendar_event_ids",
"sale_planner_calendar_event_ids.sale_planner_state",
"sale_planner_calendar_event_ids.sale_ids",
"sale_planner_calendar_event_ids.off_planning",
)
def _compute_event_planner_count(self):
for summary in self:
event_total_count = 0
event_done_count = 0
event_effective_count = 0
event_off_planning_count = 0
payment_count = 0
payment_amount = 0.0
for event in summary.sale_planner_calendar_event_ids:
event_total_count += 1
if event.sale_planner_state == "done":
event_done_count += 1
if event.sale_ids:
event_effective_count += 1
if event.off_planning:
event_off_planning_count += 1
for payment in event.payment_sheet_line_ids:
payment_count += 1
payment_amount += payment.amount
summary.event_total_count = event_total_count
summary.event_done_count = event_done_count
summary.event_effective_count = event_effective_count
summary.event_off_planning_count = event_off_planning_count
summary.payment_count = payment_count
summary.payment_amount = payment_amount
@api.constrains("user_id", "date", "event_type_id")
def _check_existing_summaries(self):
for rec in self:
summaries = self.search(
[
("user_id", "=", rec.user_id.id),
("date", "=", rec.date),
("event_type_id", "=", rec.event_type_id.id),
("id", "!=", rec.id),
]
)
if summaries:
raise ValidationError(
_(
"Already exists a summary with same user, date and event type)\n"
"Access with 'Sale planner calendar summary' menu option"
)
)
def name_get(self):
res = []
DateField = self.env["ir.qweb.field.date"]
for line in self:
name = "{} {}".format(
DateField.value_to_html(line.date, {}), line.user_id.name
)
res.append((line.id, name))
return res
def action_open_sale_order(self):
"""
Search or Create an event planner linked to sale order
"""
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale.action_quotations_with_onboarding"
)
action["context"] = {
"default_user_id": self.user_id.id,
}
if len(self.sale_ids) > 1:
action["domain"] = [
(
"sale_planner_calendar_event_id",
"in",
self.sale_planner_calendar_event_ids.ids,
)
]
else:
action["views"] = [(self.env.ref("sale.view_order_form").id, "form")]
action["res_id"] = self.sale_ids.id
return action
@api.model
def action_get_today_summary(self):
domain = [
("user_id", "=", self.env.user.id),
("date", "=", fields.Date.today()),
]
summary = self.search(domain)
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_planner_calendar.action_sale_planner_calendar_summary"
)
if len(summary) > 1:
action["domain"] = [("id", "in", summary.ids)]
else:
action["views"] = [
(
self.env.ref(
"sale_planner_calendar.view_sale_planner_calendar_summary_form"
).id,
"form",
)
]
action["res_id"] = summary.id
return action
def action_done(self):
self.write(
{
"state": "done",
# "comment": 'Done',
}
)
def action_cancel(self):
self.write(
{
"state": "cancel",
"comment": "Not done",
}
)
def action_pending(self):
self.write(
{
"state": "pending",
"comment": False,
}
)
def action_process(self):
calendar_event_domain = [
(
"start",
">=",
self._get_datetime_from_date_tz_hour(self.date, "00:00:00"),
),
(
"start",
"<=",
self._get_datetime_from_date_tz_hour(self.date, "23:59:59"),
),
("user_id", "=", self.user_id.id),
("target_partner_id", "!=", False),
("calendar_summary_id", "=", False),
]
if self.event_type_id:
calendar_event_domain.append(("categ_ids", "in", self.event_type_id.ids))
calendar_events = self.env["calendar.event"].search(calendar_event_domain)
calendar_events.calendar_summary_id = self
#
# event_planner_domain = [
# (
# "start",
# ">=",
# self._get_datetime_from_date_tz_hour(self.date, "00:00:00"),
# ),
# (
# "start",
# "<=",
# self._get_datetime_from_date_tz_hour(self.date, "23:59:59"),
# ),
# ("user_id", "=", self.user_id.id),
# "|",
# ("calendar_summary_id", "=", False),
# ("calendar_summary_id", "=", self.id),
# ]
# events_planner = self.env["calendar.event"].search(
# event_planner_domain
# )
# # We can not do a typical search due to returned virtual ids like this
# # ("calendar_event_id.categ_ids", "in", self.event_type_id.ids)
# if self.event_type_id:
# events_planner = events_planner.filtered(
# lambda p: self.event_type_id.id in p.calendar_event_id.categ_ids.ids
# )
#
# for calendar_event in calendar_events:
# event_planner = events_planner.filtered(
# lambda r: r.start
# == fields.Datetime.to_datetime(calendar_event.start)
# and r.partner_id == calendar_event.target_partner_id
# and r.user_id == calendar_event.user_id
# )
# if event_planner:
# if event_planner.calendar_summary_id != self:
# event_planner.calendar_summary_id = self
# event_planner.off_planning = True
# else:
# calendar_event.with_context(
# default_calendar_summary_id=self.id,
# default_date=calendar_event.start,
# )._create_event_planner()
# Search sale orders off planning
date_from = self._get_datetime_from_date_tz_hour(
self.date, self.env.company.sale_planner_order_cut_hour
)
date_to = date_from + timedelta(days=1)
sales = self.env["sale.order"].search(
[
("user_id", "=", self.user_id.id),
("date_order", ">=", date_from),
("date_order", "<", date_to),
("sale_planner_calendar_event_id", "=", False),
]
)
sales.action_set_planner_calendar_event(self)
@api.model
def _get_datetime_from_date_tz_hour(self, date, hour_float):
"""
Compute date in UTC format
:return: Datetime in UTC format
"""
if isinstance(hour_float, str):
hour_str = hour_float
else:
hour_str = str(timedelta(hours=hour_float)).zfill(8)
date_str = "{} {}".format(fields.Date.to_string(date), hour_str)
date_time = fields.Datetime.to_datetime(date_str)
user_tz = pytz.timezone(self.env.user.tz)
utc_tz = pytz.timezone("UTC")
time_utc = user_tz.localize(date_time).astimezone(utc_tz).replace(tzinfo=None)
return time_utc
def action_event_planner(self):
self.action_process()
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_planner_calendar.action_sale_planner_calendar_event"
)
action["domain"] = [("id", "in", self.sale_planner_calendar_event_ids.ids)]
action["context"] = {
"default_off_planning": True,
"default_calendar_summary_id": self.id,
"search_default_state_pending": 1,
}
action["views"] = [
(
self.env.ref(
"sale_planner_calendar.view_sale_planner_calendar_kanban"
).id,
"kanban",
),
(
self.env.ref(
"sale_planner_calendar.view_sale_planner_calendar_form"
).id,
"form",
),
(
self.env.ref(
"sale_planner_calendar.view_sale_planner_calendar_tree"
).id,
"tree",
),
]
action["view_mode"] = "kanban,form,tree"
return action
def action_open_payment_sheet(self):
"""
Open payments sheets related to any event
"""
payment_sheets = self.sale_planner_calendar_event_ids.mapped(
"payment_sheet_line_ids.sheet_id"
)
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_payment_sheet.action_sale_payment_sheet"
)
if len(payment_sheets) > 1:
action["domain"] = [("id", "in", payment_sheets.ids)]
else:
action["views"] = [
(
self.env.ref("sale_payment_sheet.view_sale_payment_sheet_form").id,
"form",
)
]
action["res_id"] = payment_sheets.id
return action
def action_open_issue(self):
"""
Open issues related to any event
"""
issues = self.sale_planner_calendar_event_ids.filtered("calendar_issue_type_id")
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_planner_calendar.action_sale_planner_calendar_issue_tree"
)
action["domain"] = [("id", "in", issues.ids)]
return action

View file

@ -0,0 +1,21 @@
You can find the following configurations in the settings:
#. **Subscriptions Backward Days**: Backward days to search documents to update
subscriptions.
#. **Sale Planner Forward Months**: Forward months to create calendar events.
#. **Send invitation to attendees**: Send invitations to attendees when a planner event
is created.
#. **Sale planner order cut hour**: Time of the next day until which orders of the
current day are assigned.
#. **Calendar event max duration**: Show a warning message when duration is more than
this time. Set 00:00 to disable warning.
Other setting are available with system parameters
#. **Sale order partner** when a so is created from a event planned. You can create or
update the system parameter **sale_planner_calendar.create_so_to_commercial_partner**
with True value to create the sale order to commercial partner instead of partner

View file

@ -0,0 +1,6 @@
* `Tecnativa <https://www.tecnativa.com>`__:
* Sergio Teruel
* Carlos Dauden
* Carlos Roca
* Pilar Vargas

View file

@ -0,0 +1 @@
This module allows to manage commercial visits to partners by using recurrence events.

View file

@ -0,0 +1,28 @@
You can create now the recurrent events directly from the partners by clicking next
smart button:
.. image:: ../static/img/smart_button.png
By default the end of the recurrence is set by the settings field
**Sale Planner Forward Months**.
You can manage this new recurrent events from *Calendar planner* menu entry.
.. image:: ../static/img/menu_entry.png
On the first window, you will find a summary of the events that the user has to do
today.
By going to *Calendar planner > Calendar events* you will have two options:
#. View the calendar events related to your user (*My Calendar*).
#. View your base events related to the recurrences (*Recurrent calendar events*).
Finally on *Calendar planner > Wizards* you will have again two options:
#. You can change the hour of the start of all the events related to a recurrency by using
*Sale planner calendar wizard*.
#. You can change the salesperson assigned to events related to a period of time by using
the wizard *Reassignment of salesperson*.

View file

@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_sale_planner_calendar_summary,access_sale_planner_calendar_summary,model_sale_planner_calendar_summary,sales_team.group_sale_salesman,1,1,1,1
access_sale_planner_calendar_issue_type,access_sale_planner_calendar_issue_type,model_sale_planner_calendar_issue_type,sales_team.group_sale_salesman,1,1,1,1
access_sale_planner_calendar_reassign_wiz,access_sale_planner_calendar_reassign_wiz,model_sale_planner_calendar_reassign_wiz,sales_team.group_sale_salesman,1,1,1,1
access_sale_planner_calendar_reassign_line_wiz,access_sale_planner_calendar_reassign_line_wiz,model_sale_planner_calendar_reassign_line_wiz,sales_team.group_sale_salesman,1,1,1,1
access_sale_planner_calendar_wizard,access_sale_planner_calendar_wizard,model_sale_planner_calendar_wizard,sales_team.group_sale_salesman,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_sale_planner_calendar_summary access_sale_planner_calendar_summary model_sale_planner_calendar_summary sales_team.group_sale_salesman 1 1 1 1
3 access_sale_planner_calendar_issue_type access_sale_planner_calendar_issue_type model_sale_planner_calendar_issue_type sales_team.group_sale_salesman 1 1 1 1
4 access_sale_planner_calendar_reassign_wiz access_sale_planner_calendar_reassign_wiz model_sale_planner_calendar_reassign_wiz sales_team.group_sale_salesman 1 1 1 1
5 access_sale_planner_calendar_reassign_line_wiz access_sale_planner_calendar_reassign_line_wiz model_sale_planner_calendar_reassign_line_wiz sales_team.group_sale_salesman 1 1 1 1
6 access_sale_planner_calendar_wizard access_sale_planner_calendar_wizard model_sale_planner_calendar_wizard sales_team.group_sale_salesman 1 1 1 1

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record id="partner_all_documents_follower_rule" model="ir.rule">
<field name="name">Partner all documents follower</field>
<field ref="base.model_res_partner" name="model_id" />
<field name="domain_force">[(1, '=', 1)]</field>
<field
name="groups"
eval="[(4, ref('sales_team.group_sale_salesman_all_leads'))]"
/>
</record>
<record id="partner_follower_rule" model="ir.rule">
<field name="name">Partner Follower</field>
<field ref="base.model_res_partner" name="model_id" />
<field
name="domain_force"
>[('message_partner_ids', 'in', user.partner_id.ids)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]" />
</record>
<record id="sale_order_follower_rule" model="ir.rule">
<field name="name">Sales Follower Orders</field>
<field ref="sale.model_sale_order" name="model_id" />
<field
name="domain_force"
>['|', ('message_partner_ids', 'in', user.partner_id.ids), ('partner_id.message_partner_ids', 'in', user.partner_id.ids)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]" />
</record>
<record id="sale_order_line_follower_rule" model="ir.rule">
<field name="name">Sales Follower Order Lines</field>
<field ref="sale.model_sale_order_line" name="model_id" />
<field
name="domain_force"
>['|', ('order_id.message_partner_ids', 'in', user.partner_id.ids), ('order_partner_id.message_partner_ids', 'in', user.partner_id.ids)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]" />
</record>
<record id="account_move_follower_rule" model="ir.rule">
<field name="name">Sales Follower Invoices</field>
<field ref="account.model_account_move" name="model_id" />
<field
name="domain_force"
>[('message_partner_ids', 'in', user.partner_id.ids)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]" />
</record>
<record id="account_move_line_follower_rule" model="ir.rule">
<field name="name">Sales Follower Invoice Lines</field>
<field ref="account.model_account_move_line" name="model_id" />
<field
name="domain_force"
>[('move_id.message_partner_ids', 'in', user.partner_id.ids)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]" />
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1,476 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Sale planner calendar</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="sale-planner-calendar">
<h1 class="title">Sale planner calendar</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:9536f0a762b2909e0b503ba68e80f5fbd7d358124a934194fbc5d67b657e4510
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/sale-workflow/tree/16.0/sale_planner_calendar"><img alt="OCA/sale-workflow" src="https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_planner_calendar"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module allows to manage commercial visits to partners by using recurrence events.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="toc-entry-2">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-3">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-4">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-5">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-6">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-7">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
<p>You can find the following configurations in the settings:</p>
<ol class="arabic simple">
<li><strong>Subscriptions Backward Days</strong>: Backward days to search documents to update
subscriptions.</li>
<li><strong>Sale Planner Forward Months</strong>: Forward months to create calendar events.</li>
<li><strong>Send invitation to attendees</strong>: Send invitations to attendees when a planner event
is created.</li>
<li><strong>Sale planner order cut hour</strong>: Time of the next day until which orders of the
current day are assigned.</li>
<li><strong>Calendar event max duration</strong>: Show a warning message when duration is more than
this time. Set 00:00 to disable warning.</li>
</ol>
<p>Other setting are available with system parameters</p>
<ol class="arabic simple">
<li><strong>Sale order partner</strong> when a so is created from a event planned. You can create or
update the system parameter <strong>sale_planner_calendar.create_so_to_commercial_partner</strong>
with True value to create the sale order to commercial partner instead of partner</li>
</ol>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
<p>You can create now the recurrent events directly from the partners by clicking next
smart button:</p>
<img alt="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_planner_calendar/static/img/smart_button.png" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_planner_calendar/static/img/smart_button.png" />
<p>By default the end of the recurrence is set by the settings field
<strong>Sale Planner Forward Months</strong>.</p>
<p>You can manage this new recurrent events from <em>Calendar planner</em> menu entry.</p>
<img alt="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_planner_calendar/static/img/menu_entry.png" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_planner_calendar/static/img/menu_entry.png" />
<p>On the first window, you will find a summary of the events that the user has to do
today.</p>
<p>By going to <em>Calendar planner &gt; Calendar events</em> you will have two options:</p>
<ol class="arabic simple">
<li>View the calendar events related to your user (<em>My Calendar</em>).</li>
<li>View your base events related to the recurrences (<em>Recurrent calendar events</em>).</li>
</ol>
<p>Finally on <em>Calendar planner &gt; Wizards</em> you will have again two options:</p>
<ol class="arabic simple">
<li>You can change the hour of the start of all the events related to a recurrency by using
<em>Sale planner calendar wizard</em>.</li>
<li>You can change the salesperson assigned to events related to a period of time by using
the wizard <em>Reassignment of salesperson</em>.</li>
</ol>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-3">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/sale-workflow/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/sale-workflow/issues/new?body=module:%20sale_planner_calendar%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-4">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-5">Authors</a></h2>
<ul class="simple">
<li>Tecnativa</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-6">Contributors</a></h2>
<ul class="simple">
<li><a class="reference external" href="https://www.tecnativa.com">Tecnativa</a>:<ul>
<li>Sergio Teruel</li>
<li>Carlos Dauden</li>
<li>Carlos Roca</li>
<li>Pilar Vargas</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-7">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/sale-workflow/tree/16.0/sale_planner_calendar">OCA/sale-workflow</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View file

@ -0,0 +1,50 @@
/** @odoo-module **/
import {registerPatch} from "@mail/model/model_core";
import {attr} from "@mail/model/model_field";
registerPatch({
name: "ActivityGroup",
modelMethods: {
convertData(data) {
const data2 = this._super(data);
data2.is_planner = data.is_planner;
return data2;
},
},
fields: {
is_planner: attr({default: false}),
},
});
registerPatch({
name: "ActivityGroupView",
recordMethods: {
onClickFilterButton(ev) {
this._super(...arguments);
const data = _.extend({}, $(ev.currentTarget).data(), $(ev.target).data());
if (data.is_planner === 1) {
const context = {};
if (data.filter === "my") {
context.search_default_planner_overdue = 1;
context.search_default_planner_today = 1;
} else {
context["search_default_planner_" + data.filter] = 1;
}
this.env.services.orm
.call("res.users", "get_action_sale_planner_calendar_event")
.then((action) => {
action.domain = data.domain;
const action_ctx = JSON.parse(
action.context.replace(/'/g, '"')
);
action.context = {...action_ctx, ...context};
delete action.context.search_default_my_event_planner;
this.env.services.action.doAction(action, {
clearBreadcrumbs: true,
});
});
}
},
},
});

View file

@ -0,0 +1,15 @@
/** @odoo-module **/
import {registry} from "@web/core/registry";
import {Component} from "@odoo/owl";
class CategIconsWidget extends Component {
setup() {
const categIcons = this.props.record.data.categ_icons;
this.iconList = categIcons ? categIcons.split(",") : [];
}
}
CategIconsWidget.template = "sale_planner_calendar.CategIconsWidget";
registry.category("fields").add("categ_icons_widget", CategIconsWidget);
export default CategIconsWidget;

View file

@ -0,0 +1,39 @@
/** @odoo-module **/
import {FormController} from "@web/views/form/form_controller";
import {patch} from "@web/core/utils/patch";
import {useAskRecurrenceUpdatePolicy} from "@calendar/views/ask_recurrence_update_policy_hook";
patch(FormController.prototype, "sale_planner_calendar.FormController", {
setup() {
this._super(...arguments);
this.askRecurrenceUpdatePolicy = useAskRecurrenceUpdatePolicy();
},
async deleteRecord() {
const _super = this._super.bind(this);
var record = this.model.root.data;
if (
this.props.context &&
this.props.context.choose_unlink_method &&
record.recurrency
) {
const recurrenceUpdate = await this.askRecurrenceUpdatePolicy();
if (recurrenceUpdate && recurrenceUpdate !== "self_only") {
await this._launchMassDeletion(record, recurrenceUpdate);
return this.env.config.historyBack();
} else if (recurrenceUpdate) {
await this.model.root.delete();
return this.env.config.historyBack();
}
return;
}
return _super(...arguments);
},
_launchMassDeletion: async function (record, recurrenceUpdate) {
const resId = record.id;
await this.env.services.orm.call(this.props.resModel, "action_mass_deletion", [
[resId],
recurrenceUpdate,
]);
},
});

View file

@ -0,0 +1,54 @@
/** @odoo-module **/
import {ListController} from "@web/views/list/list_controller";
import {patch} from "@web/core/utils/patch";
import {useAskRecurrenceUpdatePolicy} from "@calendar/views/ask_recurrence_update_policy_hook";
patch(ListController.prototype, "sale_planner_calendar.ListController", {
setup() {
this._super(...arguments);
this.askRecurrenceUpdatePolicy = useAskRecurrenceUpdatePolicy();
},
async onDeleteSelectedRecords() {
const _super = this._super.bind(this);
const resIds = await this.model.root.getResIds(true);
var records = this.model.root.records.filter((record) =>
resIds.includes(record.resId)
);
if (
this.props.context &&
this.props.context.choose_unlink_method &&
!records.some((record) => !record.data.recurrency)
) {
const recurrenceUpdate = await this.askRecurrenceUpdatePolicy();
if (recurrenceUpdate && recurrenceUpdate !== "self_only") {
await this._launchMassDeletion(records, recurrenceUpdate);
await this.model.load();
return this.model.notify();
} else if (recurrenceUpdate) {
await this.model.root.deleteRecords();
return this.model.notify();
}
return;
}
return _super(...arguments);
},
_launchMassDeletion: async function (records, recurrenceUpdate) {
let recs = [...records];
while (recs.length) {
const record = recs[0];
const recordsSameRecurrence = recs.filter(
(rec) => rec.resId === record.resId
);
const event = recordsSameRecurrence.reduce((prev, curr) => {
return prev.resId < curr.resId ? prev : curr;
});
await this.env.services.orm.call(
this.props.resModel,
"action_mass_deletion",
[[event.resId], recurrenceUpdate]
);
recs = recs.filter((el) => !recordsSameRecurrence.includes(el));
}
},
});

View file

@ -0,0 +1,35 @@
/** @odoo-module **/
import {KanbanController} from "@web/views/kanban/kanban_controller";
import {kanbanView} from "@web/views/kanban/kanban_view";
import {registry} from "@web/core/registry";
import {useService} from "@web/core/utils/hooks";
export class SalePlannerCalendarEventKanbanController extends KanbanController {
setup() {
super.setup();
this.orm = useService("orm");
this.action = useService("action");
}
async onClickNewSaleOrder() {
console.log(this);
const calendar_summary_id = this.props.context.default_calendar_summary_id;
const action = await this.orm.call(
"calendar.event",
"action_open_sale_order",
[false, {new_order: true}],
{context: {calendar_summary_id: calendar_summary_id || false}}
);
this.action.doAction(action);
}
}
export const SalePlannerCalendarEventKanbanView = {
...kanbanView,
Controller: SalePlannerCalendarEventKanbanController,
buttonTemplate: "SalePlannerCalendarEventKanbanView.buttons",
};
registry
.category("views")
.add("sale_planner_calendar_event_kanban", SalePlannerCalendarEventKanbanView);

View file

@ -0,0 +1,35 @@
/** @odoo-module **/
import {ListController} from "@web/views/list/list_controller";
import {listView} from "@web/views/list/list_view";
import {registry} from "@web/core/registry";
import {useService} from "@web/core/utils/hooks";
export class SalePlannerCalendarEventListController extends ListController {
setup() {
super.setup();
this.orm = useService("orm");
this.action = useService("action");
}
async onClickNewSaleOrder() {
console.log(this);
const calendar_summary_id = this.props.context.default_calendar_summary_id;
const action = await this.orm.call(
"calendar.event",
"action_open_sale_order",
[false, {new_order: true}],
{context: {calendar_summary_id: calendar_summary_id || false}}
);
this.action.doAction(action);
}
}
export const SalePlannerCalendarEventListView = {
...listView,
Controller: SalePlannerCalendarEventListController,
buttonTemplate: "SalePlannerCalendarEventListView.buttons",
};
registry
.category("views")
.add("sale_planner_calendar_event_tree", SalePlannerCalendarEventListView);

View file

@ -0,0 +1,7 @@
.oe_kanban_card_full_width {
width: 100% !important;
}
.o_kanban_renderer.o_sale_planner_calendar_kanban {
--KanbanRecord-width: 100%;
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-inherit="mail.ActivityMenuView" t-inherit-mode="extension">
<xpath
expr="//*[hasclass('o_ActivityMenuView_activityGroup')]"
position="attributes"
>
<attribute
name="t-att-data-is_planner"
>activityGroupView.activityGroup.is_planner ? 1 : 0</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="sale_planner_calendar.CategIconsWidget" owl="1">
<div class="o_categ_icons_widget">
<t t-foreach="iconList" t-as="categ_icon" t-key="categ_icon">
<i t-att-class="'me-1 fa ' + categ_icon" />
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates>
<t t-name="sale_planner_calendar.newSaleOrderButton" owl="1">
<button
type="button"
class="btn btn-secondary o_button_new_sale_order"
t-on-click="onClickNewSaleOrder"
>
New Quotation
</button>
</t>
<t
t-name="SalePlannerCalendarEventListView.buttons"
t-inherit="web.ListView.Buttons"
t-inherit-mode="primary"
owl="1"
>
<xpath expr="//button[hasclass('o_list_button_add')]" position="after">
<t t-call="sale_planner_calendar.newSaleOrderButton" />
</xpath>
</t>
<t
t-name="SalePlannerCalendarEventKanbanView.buttons"
t-inherit="web.KanbanView.Buttons"
t-inherit-mode="primary"
owl="1"
>
<xpath expr="//button[hasclass('o-kanban-button-new')]" position="after">
<t t-call="sale_planner_calendar.newSaleOrderButton" />
</xpath>
</t>
</templates>

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import test_sale_planner_calendar

View file

@ -0,0 +1,376 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from datetime import date, timedelta
from freezegun import freeze_time
from odoo.exceptions import AccessError
from odoo.tests.common import Form, TransactionCase, tagged
@tagged("-at_install", "post_install")
@freeze_time("2022-02-04 09:00:00")
class TestSalePlannerCalendar(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Remove this variable in v16 and put instead:
# from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT
DISABLED_MAIL_CONTEXT = {
"tracking_disable": True,
"mail_create_nolog": True,
"mail_create_nosubscribe": True,
"mail_notrack": True,
"no_reset_password": True,
}
cls.env = cls.env(context=dict(cls.env.context, **DISABLED_MAIL_CONTEXT))
cls.CalendarEvent = cls.env["calendar.event"]
cls.ResUsers = cls.env["res.users"]
cls.Partner = cls.env["res.partner"]
cls.ProductTemplate = cls.env["product.template"]
cls.Product = cls.env["product.product"]
cls.AccountInvoice = cls.env["account.move"]
cls.AccountInvoiceLine = cls.env["account.move.line"]
cls.AccountJournal = cls.env["account.journal"]
cls.SaleOrder = cls.env["sale.order"]
cls.SalePlannerCalendarEvent = cls.env["calendar.event"]
account_group = cls.env.ref("account.group_account_user")
cls.env.user.write({"groups_id": [(4, account_group.id)]})
cls.event_type_commercial_visit = cls.env.ref(
"sale_planner_calendar.event_type_commercial_visit"
)
cls.event_type_delivery = cls.env.ref(
"sale_planner_calendar.event_type_delivery"
)
cls.pricelist = cls.env["product.pricelist"].create(
{"name": "Test pricelist", "currency_id": cls.env.company.currency_id.id}
)
# Create some products
cls._create_products()
# Create some commercial users
cls._create_commercial_users()
# Create some partners
cls._create_partners()
# Create some calendar planner events
cls.create_calendar_planner_event()
# Some account data
cls.account = cls.env["account.account"].create(
{
"code": "test",
"name": "Test account",
"account_type": "income",
}
)
@classmethod
def _create_commercial_users(cls):
# Create commercial_user_1 and commercial_user_2 with Own Documents
# Only security group
cls.commercial_users = cls.ResUsers.browse()
for i in range(2):
index = i + 1
user = cls.ResUsers.create(
{
"name": "Commercial user %s" % index,
"login": "Commercial user %s" % index,
"groups_id": [
(4, cls.env.ref("sales_team.group_sale_salesman").id)
],
}
)
setattr(cls, "commercial_user_%s" % index, user)
cls.commercial_users |= user
@classmethod
def _create_partners(cls):
cls.partners = cls.Partner.browse()
cls.partner_1 = cls.Partner.create(
{
"name": "Partner 1",
"user_id": cls.commercial_user_1.id,
"property_product_pricelist": cls.pricelist.id,
}
)
cls.partner_2 = cls.Partner.create(
{
"name": "Partner 2",
"user_id": cls.commercial_user_1.id,
"property_product_pricelist": cls.pricelist.id,
}
)
cls.commercial_partner_3 = cls.Partner.create(
{
"name": "Company partner 3",
"user_id": cls.commercial_user_2.id,
"property_product_pricelist": cls.pricelist.id,
}
)
cls.partner_3 = cls.Partner.create(
{
"name": "Partner 3",
"user_id": cls.commercial_user_2.id,
"property_product_pricelist": cls.pricelist.id,
"parent_id": cls.commercial_partner_3.id,
}
)
cls.partners = cls.partner_1 + cls.partner_2 + cls.partner_3
@classmethod
def _create_products(cls):
cls.product = cls.Product.create(
{
"name": "Product test 1",
"list_price": 100.00,
}
)
def _create_sale_order(self):
so_form = Form(self.SaleOrder)
so_form.partner_id = self.partner_1
with so_form.order_line.new() as line_form:
line_form.product_id = self.product
line_form.tax_id.remove(index=0)
return so_form.save()
@classmethod
def create_calendar_planner_event(cls):
# Create one planned recurrent event for every partner.
cls.planned_events = cls.CalendarEvent.browse()
for i, partner in enumerate(cls.partners):
action = partner.action_calendar_planner()
context = dict(action["context"], default_wed=True, default_fri=True)
# We use Form for auto-computing the recurrence model, that is triggered
# directly from the initialization of it
event_form = Form(cls.CalendarEvent.with_context(**context))
cls.planned_events |= event_form.save()
if i == 0:
# Create a delivery event for partner 1. We can delivery goods
# all mondays at 09:00
context = dict(
action["context"],
default_name="Delivery",
default_start="2022-02-07 09:00:00",
default_stop="2022-02-07 10:00:00",
default_mon=True,
default_categ_ids=[(4, cls.event_type_delivery.id)],
)
event_form = Form(cls.CalendarEvent.with_context(**context))
cls.planned_events |= event_form.save()
def _create_sale_order_from_planner(self, event_planner_id):
so_form = Form(
self.SaleOrder.with_context(
default_user_id=event_planner_id.user_id.id,
default_sale_planner_calendar_event_id=event_planner_id.id,
default_partner_id=event_planner_id.target_partner_id.id,
)
)
with so_form.order_line.new() as line_form:
line_form.product_id = self.product
line_form.product_uom_qty = 1
line_form.tax_id.remove(index=0)
return so_form.save()
def _create_invoice(self, partner):
with Form(
self.env["account.move"].with_context(default_move_type="out_invoice")
) as invoice_form:
invoice_form.partner_id = partner
with invoice_form.invoice_line_ids.new() as line_form:
line_form.name = "invoice test"
line_form.account_id = self.account
line_form.quantity = 1.0
line_form.price_unit = 100.00
line_form.tax_ids.remove(index=0)
return invoice_form.save()
def test_create_calendar_planner_event(self):
# Test the values for one planned recurrent event created
event = self.planned_events[0]
self.assertTrue(event.user_id in self.commercial_users)
self.assertEqual(event.rrule_type, "weekly")
self.assertEqual(
event.location,
event.target_partner_id._display_address(True).replace("\n", " "),
)
def test_planner_calendar_wizard(self):
wiz_form = Form(self.env["sale.planner.calendar.wizard"])
# This user has three planned events
wiz_form.user_id = self.commercial_user_1
self.assertEqual(len(wiz_form.calendar_event_ids), 3)
wiz_form.event_type_id = self.event_type_delivery
self.assertEqual(len(wiz_form.calendar_event_ids), 1)
wiz_form.event_type_id = self.event_type_commercial_visit
self.assertEqual(len(wiz_form.calendar_event_ids), 2)
def test_summary_and_event_today(self):
summary_obj = self.env["sale.planner.calendar.summary"]
summary_form = Form(summary_obj)
summary_form.user_id = self.commercial_user_1
summary = summary_form.save()
summary.action_process()
self.assertEqual(summary.event_total_count, 2)
event_planner_id = summary.sale_planner_calendar_event_ids[0]
# Create a new sale order from planner event
self._create_sale_order_from_planner(event_planner_id)
self.assertEqual(summary.sale_order_count, 1)
self.assertEqual(summary.sale_order_subtotal, 100)
self._create_sale_order_from_planner(event_planner_id)
self.assertEqual(summary.sale_order_count, 2)
self.assertEqual(summary.sale_order_subtotal, 200)
# Create a new invoice from planner event
self.invoice1 = self._create_invoice(event_planner_id.target_partner_id)
self.invoice1.action_post()
self.assertEqual(event_planner_id.invoice_amount_residual, 100)
# Set event to done state
event_planner_id.action_done()
self.assertEqual(summary.event_total_count, 2)
self.assertEqual(summary.event_done_count, 1)
self.assertEqual(summary.event_effective_count, 1)
def test_reassign_wizard(self):
wiz_form = Form(self.env["sale.planner.calendar.reassign.wiz"])
wiz_form.user_id = self.commercial_user_1
wiz_form.new_start = date.today()
record = wiz_form.save()
# Recover all planned event lines for commercial user 1
record.action_get_lines()
self.assertEqual(len(record.line_ids), 3)
# Select line behaviour for update new commercial user
wiz_form.new_user_id = self.commercial_user_2
record = wiz_form.save()
record.select_all_lines()
record.action_assign_new_values()
self.assertEqual(len(record.line_ids.mapped("new_user_id")), 1)
wiz_form.new_user_id = self.commercial_user_2
record = wiz_form.save()
record.line_ids = False
record.action_get_lines()
record.line_ids[0].selected = True
record.action_assign_new_values()
self.assertEqual(len(record.line_ids.filtered(lambda ln: ln.new_user_id)), 1)
def test_reassign_wizard_apply(self):
# When creating new recurring events for reallocated changes,
# each event must have a new recurrence. This test is
# incorporated to control that no event is left without recurrence.
wiz_form = Form(self.env["sale.planner.calendar.reassign.wiz"])
wiz_form.user_id = self.commercial_user_1
wiz_form.assign_new_salesperson_to_partner = True
wiz_form.new_start = date.today() + timedelta(days=8)
wiz_form.new_end = wiz_form.new_start + timedelta(days=20)
record = wiz_form.save()
record.action_get_lines()
record.line_ids[0].new_user_id = self.commercial_user_2
old_event = record.line_ids[0].calendar_event_id
recurrence_events = old_event.recurrence_id.calendar_event_ids
new_base_event_start = recurrence_events.filtered(
lambda ce: ce.start.date() >= record.new_start
).sorted("start")[:1]
self.assertTrue(new_base_event_start.recurrence_id)
self.assertEqual(new_base_event_start.recurrence_id, old_event.recurrence_id)
new_base_event_end = recurrence_events.filtered(
lambda ce: ce.start.date() >= record.new_end
).sorted("start")[:1]
self.assertTrue(new_base_event_end.recurrence_id)
self.assertEqual(new_base_event_end.recurrence_id, old_event.recurrence_id)
record.apply()
# Events created for changes must have a new recurrence created from the old event
self.assertTrue(
self.env["calendar.event"].browse(new_base_event_start.id).recurrence_id
)
self.assertNotEqual(
self.env["calendar.event"].browse(new_base_event_start.id).recurrence_id,
old_event.recurrence_id,
)
self.assertTrue(
self.env["calendar.event"].browse(new_base_event_end.id).recurrence_id
)
self.assertNotEqual(
self.env["calendar.event"].browse(new_base_event_end.id).recurrence_id,
old_event.recurrence_id,
)
# The original event must maintain its recurrence
self.assertEqual(
self.env["calendar.event"].browse(old_event.id).recurrence_id,
old_event.recurrence_id,
)
def test_reassign_wizard_subscriptions(self):
# Create a SO for partner 1 and user commercial 1
sale_order = self._create_sale_order()
invoice = self._create_invoice(self.partner_1)
# Check document permissions based on followers
with self.assertRaises(AccessError):
self.assertIsNotNone(
sale_order.with_user(self.commercial_user_2).check_access_rule("read")
)
self.assertIsNotNone(
sale_order.with_user(self.commercial_user_2).check_access_rule("write")
)
self.assertIsNotNone(
invoice.with_user(self.commercial_user_2).check_access_rule("read")
)
self.assertIsNotNone(
invoice.with_user(self.commercial_user_2).check_access_rule("write")
)
wiz_form = Form(self.env["sale.planner.calendar.reassign.wiz"])
wiz_form.user_id = self.commercial_user_1
wiz_form.new_start = date.today()
record = wiz_form.save()
# Recover all planned event lines for commercial user 1
record.action_get_lines()
wiz_form.new_user_id = self.commercial_user_2
record = wiz_form.save()
event_planner_partner_1 = record.line_ids.filtered(
lambda ln: ln.partner_id == self.partner_1
)
event_planner_partner_1.selected = True
record.action_assign_new_values()
record.apply()
# Check document permissions based on followers
# Sale order
self.assertIsNone(
sale_order.with_user(self.commercial_user_2).check_access_rule("read")
)
self.assertIsNone(
sale_order.with_user(self.commercial_user_2).check_access_rule("write")
)
# Account move (Invoice)
self.assertIsNone(
invoice.with_user(self.commercial_user_2).check_access_rule("read")
)
self.assertIsNone(
invoice.with_user(self.commercial_user_2).check_access_rule("write")
)
def test_parter_sale_order(self):
"""User can setup a system parameter to create sale order from a event planner
for a event planner partner or commercial partner
"""
sale_planned_event = self.planned_events.filtered(
lambda p: p.target_partner_id == self.partner_3
)[:1]
so_action = sale_planned_event.action_open_sale_order()
self.assertEqual(so_action["context"]["default_partner_id"], self.partner_3.id)
# Set parameter to create sale order to commercial partner
self.env["ir.config_parameter"].sudo().set_param(
"sale_planner_calendar.create_so_to_commercial_partner", "True"
)
so_action = sale_planned_event.action_open_sale_order()
self.assertEqual(
so_action["context"]["default_partner_id"],
self.partner_3.commercial_partner_id.id,
)

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_calendar_event_type_tree" model="ir.ui.view">
<field name="name">calendar.event.type</field>
<field name="model">calendar.event.type</field>
<field name="inherit_id" ref="calendar.view_calendar_event_type_tree" />
<field name="arch" type="xml">
<field name="name" position="after">
<field name="duration" />
<field name="icon" />
</field>
</field>
</record>
</odoo>

View file

@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_calendar_event_tree" model="ir.ui.view">
<field name="model">calendar.event</field>
<field name="inherit_id" ref="calendar.view_calendar_event_tree" />
<field name="arch" type="xml">
<field name="duration" position="after">
<field name="until" optional="hide" />
<field name="recurrence_id" invisible="1" />
</field>
</field>
</record>
<record id="view_calendar_event_tree_primary_only" model="ir.ui.view">
<field name="name">calendar.event.primary.only</field>
<field name="model">calendar.event</field>
<field name="priority" eval="9999" />
<field name="arch" type="xml">
<tree default_order="start">
<field name="user_id" optional="show" />
<field name="name" string="Subject" optional="show" />
<field name="target_partner_id" optional="hide" />
<field name="start" string="Start Date" optional="show" />
<field name="until" optional="hide" />
<field
name="categ_ids"
widget="many2many_tags"
optional="show"
options="{'color_field': 'color', 'no_create_edit': True}"
/>
<field name="location" optional="hide" />
<field name="duration" widget="float_time" optional="hide" />
<field name="recurrency" optional="hide" />
<field name="recurrence_id" invisible="1" />
</tree>
</field>
</record>
<record id="calendar_event_tree_primary_only_action" model="ir.actions.act_window">
<field name="name">Recurrent calendar events</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">calendar.event</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_calendar_event_tree_primary_only" />
<field name="context">{
'default_recurrency':True,
'search_default_user_id':uid,
'choose_unlink_method': True,
}</field>
<field name="domain">[('recurrency', '=', True),
("is_base_recurrent_event", "=", True), ('target_partner_id', '!=', False), '|', ('recurrence_id.until', '>=', datetime.date.today().strftime("%Y-%m-%d")), ('recurrence_id.until', '=', False)]</field>
</record>
<record id="view_calendar_event_search" model="ir.ui.view">
<field name="model">calendar.event</field>
<field name="inherit_id" ref="calendar.view_calendar_event_search" />
<field name="arch" type="xml">
<field name="partner_ids" position="before">
<field name="target_partner_id" />
</field>
<filter name="inactive" position="before">
<separator />
<filter
string="Today"
name="start_today"
domain="[('start','&gt;=', time.strftime('%%Y-%%m-%%d 00:00:00')),('start','&lt;',(datetime.date.today() + datetime.timedelta(days=1)).strftime('%%Y-%%m-%%d 00:00:00'))]"
/>
<filter
string="Tomorrow"
name="start_tomorrow"
domain="[('start','&gt;', time.strftime('%%Y-%%m-%%d 23:59:59')),('start','&lt;',(datetime.date.today() + datetime.timedelta(days=2)).strftime('%%Y-%%m-%%d 00:00:00'))]"
/>
<separator />
</filter>
<filter name="responsible" position="after">
<filter
string="Sale planner partner"
name="group_by_target_partner"
domain="[]"
context="{'group_by': 'target_partner_id'}"
/>
<filter
string="Tags"
name="group_by_tags"
domain="[]"
context="{'group_by': 'categ_ids'}"
/>
</filter>
</field>
</record>
<record id="view_calendar_event_form" model="ir.ui.view">
<field name="model">calendar.event</field>
<field name="inherit_id" ref="calendar.view_calendar_event_form" />
<field name="arch" type="xml">
<field name="user_id" position="after">
<label for="target_partner_id" string="Sale planner partner" />
<field name="target_partner_id" nolabel="1" />
</field>
<field name="recurrence_id" position="after">
<group attrs="{'invisible': [('recurrency', '=', False)]}">
<field name="advanced_cycle" />
<field
name="cycle_number"
attrs="{'invisible': [('advanced_cycle', '=', False)]}"
/>
<field
name="cycle_skip"
attrs="{'invisible': [('advanced_cycle', '=', False)]}"
/>
</group>
</field>
</field>
</record>
<!-- TODO: Review to be removed -->
<record id="view_calendar_event_kanban" model="ir.ui.view">
<field name="name">calendar.event.kanban</field>
<field name="model">calendar.event</field>
<field name="priority" eval="32" />
<field name="arch" type="xml">
<kanban
class="o_kanban_mobile"
default_order="start"
quick_create_view="view_sale_planner_calendar_form"
on_create="action_sale_planner_calendar_event"
>
<field name="name" />
<field name="target_partner_id" />
<field name="attendee_ids" />
<field name="categ_ids" />
<field name="start" />
<field name="start_date" />
<field name="target_partner_mobile" />
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="row mb4">
<div class="col-8">
<strong><span><t
t-esc="record.start.value"
/></span></strong>
</div>
<div class="col-4 text-end">
<field
name="categ_ids"
widget="many2many_tags"
options="{'color_field': 'color'}"
/>
</div>
</div>
<div class="row">
<div class="col-8">
<field name="target_partner_id" />
</div>
<div class="col-4">
<div class="float-end">
<div t-if="record.target_partner_mobile.value">
<span title="Mobile">
<i class="fa fa-phone" />
<field
name="target_partner_mobile"
widget="phone"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="action_calendar_event" model="ir.actions.act_window">
<field name="name">My Calendar</field>
<field name="res_model">calendar.event</field>
<field name="view_mode">calendar,tree,kanban,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Schedule a new calendar event
</p>
</field>
<field
name="search_view_id"
ref="sale_planner_calendar.view_calendar_event_search"
/>
<field name="context">{
'search_default_start_today': 1,
'choose_unlink_method': True,
}</field>
<field
name="domain"
>[('user_id', '=', uid), ('target_partner_id', '!=', False)]</field>
</record>
</odoo>

View file

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="sale.res_config_settings_view_form" />
<field name="arch" type="xml">
<div data-key="sale_management" position="inside">
<h2>Sale Planner Calendar</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="susbscriptions_backward_days" />
<div class="text-muted">
Backward days to search documents to update subscriptions
</div>
<div class="content-group">
<div class="mt16">
<field
name="susbscriptions_backward_days"
class="o_light_label"
/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="sale_planner_forward_months" />
<div class="text-muted">
Forward months to create calendar events
</div>
<div class="content-group">
<div class="mt16">
<field
name="sale_planner_forward_months"
class="o_light_label"
/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="sale_planner_mail_to_attendees" />
<div class="text-muted">
Send invitations to attendees when a planner event is created
</div>
<div class="content-group">
<div class="mt16">
<field
name="sale_planner_mail_to_attendees"
class="o_light_label"
/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="sale_planner_order_cut_hour" />
<div class="text-muted">
Time of the next day until which orders of the current day are assigned
</div>
<div class="content-group">
<div class="mt16">
<field
name="sale_planner_order_cut_hour"
class="o_light_label"
widget="float_time"
/>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="sale_planner_calendar_max_duration" />
<div class="text-muted">
Show a warning message when duration is more than this time.
Set 00:00 to disable warning.
</div>
<div class="content-group">
<div class="mt16">
<field
name="sale_planner_calendar_max_duration"
class="o_light_label"
widget="float_time"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</field>
</record>
</odoo>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="ir.ui.view" id="view_partner_form">
<field name="model">res.partner</field>
<field name="type">form</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button
class="oe_stat_button"
name="action_calendar_planner"
icon="fa-calendar"
type="object"
>
<span class="o_stat_text">Planner</span>
</button>
</div>
<group name="sale" position="inside">
<field
name="is_sale_planner_contact"
attrs="{'invisible': [('parent_id', '=', False)]}"
/>
</group>
</field>
</record>
</odoo>

View file

@ -0,0 +1,636 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_sale_planner_calendar_search" model="ir.ui.view">
<field name="name">sale.planner.calendar.event</field>
<field name="model">calendar.event</field>
<field name="priority">40</field>
<field name="arch" type="xml">
<search string="Search sale planner calendar events">
<field
name="name"
string="Meeting"
filter_domain="[('name', 'ilike', self)]"
/>
<field name="target_partner_id" />
<field name="user_id" string="Planner salesperson" />
<field name="partner_user_id" />
<field name="categ_ids" />
<field name="partner_ids" />
<field name="description" />
<separator />
<field name="sale_planner_state" />
<filter
string="My events"
name="my_event_planner"
domain="[('user_id','=', uid)]"
/>
<separator />
<filter
string="Pending"
name="state_pending"
domain="[('sale_planner_state','=', 'pending')]"
/>
<filter
string="Done"
name="state_done"
domain="[('sale_planner_state','=', 'done')]"
/>
<separator />
<filter
string="Past"
name="planner_overdue"
domain="[('start','&lt;=', time.strftime('%%Y-%%m-%%d 00:00:00'))]"
/>
<filter
string="Today"
name="planner_today"
domain="[('start','&gt;=', time.strftime('%%Y-%%m-%%d 00:00:00')),('start','&lt;',(datetime.date.today() + datetime.timedelta(days=1)).strftime('%%Y-%%m-%%d 00:00:00'))]"
/>
<filter
string="Tomorrow"
name="planner_tomorrow"
domain="[('start','&gt;', time.strftime('%%Y-%%m-%%d 23:59:59')),('start','&lt;',(datetime.date.today() + datetime.timedelta(days=2)).strftime('%%Y-%%m-%%d 00:00:00'))]"
/>
<filter
string="Future"
name="planner_upcoming_all"
domain="[('start','&gt;', time.strftime('%%Y-%%m-%%d 23:59:59'))]"
/>
<separator />
<group expand="0" string="Group By">
<filter
string="Planner salesperson"
name="responsible"
domain="[]"
context="{'group_by': 'user_id'}"
/>
<filter
string="Sale planner partner"
name="group_by_target_partner"
domain="[]"
context="{'group_by': 'target_partner_id'}"
/>
<filter
string="Tags"
name="group_by_tags"
domain="[]"
context="{'group_by': 'categ_ids'}"
/>
<filter
string="Start"
name="group_by_start_day"
domain="[]"
context="{'group_by':'start:day'}"
/>
<filter
string="Rating"
name="group_by_planner_rating"
domain="[]"
context="{'group_by': 'sale_planner_rating'}"
/>
</group>
</search>
</field>
</record>
<record id="view_sale_planner_calendar_tree" model="ir.ui.view">
<field name="name">sale.planner.calendar.event.tree</field>
<field name="model">calendar.event</field>
<field name="arch" type="xml">
<tree js_class="sale_planner_calendar_event_tree">
<field name="start" optional="show" />
<field name="user_id" string="Planner salesperson" optional="show" />
<field name="target_partner_id" optional="show" />
<field name="partner_user_id" optional="hide" />
<field name="name" optional="show" />
<field
name="categ_ids"
widget="many2many_tags"
optional="hide"
options="{'color_field': 'color', 'no_create_edit': True}"
/>
<field name="sale_order_subtotal" optional="show" />
<field name="sale_planner_state" optional="show" />
<field name="sale_planner_rating" optional="hide" />
<field name="sale_planner_currency_id" invisible="1" />
</tree>
</field>
</record>
<record id="view_sale_planner_calendar_form" model="ir.ui.view">
<field name="name">sale.planner.calendar.event.form</field>
<field name="model">calendar.event</field>
<field name="arch" type="xml">
<form string="Sale planner calendar event">
<sheet>
<div class="oe_button_box d-flex" name="button_box">
<button
string="Done"
class="oe_stat_button"
name="action_done"
type="object"
attrs="{'invisible':[('sale_planner_state', 'not in', ['pending'])]}"
icon="fa-check"
>
</button>
<button
class="oe_stat_button border-end"
name="action_cancel"
string="Cancel"
type="object"
attrs="{'invisible':[('sale_planner_state', 'in', ['cancel', 'done'])]}"
icon="fa-close"
/>
<button
string="Pending"
class="oe_stat_button border-end"
name="action_pending"
type="object"
attrs="{'invisible':[('sale_planner_state', 'in', ['pending'])]}"
icon="fa-pencil-square-o"
/>
<button
class="oe_stat_button ms-auto"
name="action_open_sale_order"
type="object"
icon="fa-dollar"
>
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="sale_order_subtotal" />
</span>
<span class="o_stat_text">Sales</span>
</div>
</button>
<button
class="oe_stat_button"
name="action_open_unpaid_invoice"
type="object"
icon="fa-pencil-square-o"
>
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="invoice_amount_residual" />
</span>
<span class="o_stat_text">Invoices</span>
</div>
</button>
</div>
<group>
<group>
<field name="name" />
<field name="target_partner_id" />
<!-- TODO: Add attrs="{'invisible': [('user_id', '=', partner_user_id)]}" -->
<field name="partner_user_id" />
<field name="start" />
<field name="user_id" string="Planner salesperson" />
<field name="sale_planner_currency_id" invisible="1" />
<field name="sale_planner_state" invisible="1" />
<field name="sale_order_subtotal" invisible="1" />
</group>
<group>
<field
name="alarm_ids"
widget="many2many_tags"
options="{'no_quick_create': True}"
/>
<field
name="categ_ids"
widget="many2many_tags"
options="{'color_field': 'color', 'no_create_edit': True}"
/>
<field name="calendar_issue_type_id" widget="selection" />
<field name="comment" />
<div class="display-5" colspan="2">
<field
name="sale_planner_rating"
widget="selection_badge"
/>
</div>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" />
<field name="message_ids" />
</div>
</form>
</field>
</record>
<record id="view_sale_planner_calendar_issue_form" model="ir.ui.view">
<field name="name">sale.planner.calendar.issue.form</field>
<field name="model">calendar.event</field>
<field name="priority" eval="999" />
<field name="arch" type="xml">
<form string="Sale planner calendar event">
<sheet>
<group>
<field name="target_partner_id" readonly="1" />
</group>
<group>
<field name="calendar_issue_type_id" widget="selection_badge" />
</group>
<group>
<field name="comment" />
</group>
<footer invisible="context.get('hide_footer', False)">
<button
name="action_apply_issue"
type="object"
string="Apply"
class="btn-primary"
/>
<button
string="Cancel"
class="btn-secondary"
special="cancel"
/>
</footer>
</sheet>
</form>
</field>
</record>
<record id="view_sale_planner_calendar_issue_tree" model="ir.ui.view">
<field name="name">sale.planner.calendar.issue.tree</field>
<field name="model">calendar.event</field>
<field name="arch" type="xml">
<tree>
<field name="start" optional="show" />
<field name="user_id" string="Planner salesperson" optional="show" />
<field name="target_partner_id" optional="show" />
<field name="calendar_issue_type_id" />
<field name="comment" />
<field name="sale_planner_state" optional="show" />
</tree>
</field>
</record>
<record id="action_sale_planner_calendar_issue" model="ir.actions.act_window">
<field name="name">Sale planner calendar issue</field>
<field name="res_model">calendar.event</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field
name="context"
>{'form_view_ref': 'sale_planner_calendar.view_sale_planner_calendar_issue_form'}</field>
</record>
<record id="action_sale_planner_calendar_issue_tree" model="ir.actions.act_window">
<field name="name">Sale planner calendar issue</field>
<field name="res_model">calendar.event</field>
<field name="view_mode">tree,form</field>
<field
name="context"
>{'form_view_ref': 'sale_planner_calendar.view_sale_planner_calendar_issue_form',
'tree_view_ref': 'sale_planner_calendar.view_sale_planner_calendar_issue_tree',
'hide_footer': True
}</field>
</record>
<record id="view_sale_planner_calendar_rating_form" model="ir.ui.view">
<field name="name">sale.planner.calendar.rating.form</field>
<field name="model">calendar.event</field>
<field name="priority" eval="999" />
<field name="arch" type="xml">
<form string="Sale planner calendar event">
<group>
<field name="sale_planner_rating" invisible="1" />
<field name="target_partner_id" readonly="1" />
</group>
<group colspan="4">
<label for="comment" />
<field colspan="2" name="comment" class="border" nolabel="1" />
</group>
<div class="display-1 text-center" colspan="2">
<field name="sale_planner_rating" widget="selection_badge" />
</div>
<footer>
<!-- TODO: Remove this button when click in badge closes wizard -->
<button
string="Accept"
name="action_set_sale_planner_rating"
type="object"
class="oe_highlight"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_sale_planner_calendar_rating" model="ir.actions.act_window">
<field name="name">Sale planner calendar rating</field>
<field name="res_model">calendar.event</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">
{'form_view_ref': 'sale_planner_calendar.view_sale_planner_calendar_rating_form'}
</field>
</record>
<record id="view_sale_planner_calendar_kanban" model="ir.ui.view">
<field name="name">sale.planner.calendar.event.kanban</field>
<field name="model">calendar.event</field>
<field name="arch" type="xml">
<kanban
class="o_kanban_mobile o_sale_planner_calendar_kanban"
default_order="start"
js_class="sale_planner_calendar_event_kanban"
>
<field name="name" />
<field name="target_partner_id" />
<field name="sale_planner_state" />
<field name="user_id" />
<field name="start" />
<field name="sale_ids" />
<field name="partner_ref" />
<field name="partner_name" />
<field name="partner_commercial_name" />
<field name="partner_street" />
<field name="partner_city" />
<field name="partner_mobile" />
<field name="partner_contact_name" />
<field name="sanitized_partner_mobile" />
<field name="location_url" />
<field name="calendar_issue_type_id" />
<field name="sale_planner_currency_id" />
<field name="comment" />
<field name="sale_planner_rating" />
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card w-100 oe_kanban_global_click">
<div class="row align-items-center">
<div class="col col-lg-2 col-xl-2 pe-0 text-center">
<div class="row" title="Clock">
<div class="col text-center">
<i class="fa fa-clock-o fa-2x" />
</div>
</div>
<div class="row">
<div class="col">
<field name="start" class="fw-bold" />
</div>
</div>
<div class="row">
<div class="col">
<field
name="categ_icons"
widget="categ_icons_widget"
/>
<field
name="sale_planner_state"
widget="label_selection"
class="btn btn-danger btn-lg p-0 pb-1"
attrs="{'invisible': [('sale_planner_state', '!=', 'cancel')]}"
options="{'classes': {'cancel': 'danger'}}"
/>
<button
name="action_open_rating"
type="object"
attrs="{'invisible': [('sale_planner_state', '=', 'cancel')]}"
title="Change calendar event status by adding a previous rating"
t-att-class="'btn p-0 px-1 btn-lg text-capitalize ' + (record.sale_planner_state.raw_value == 'done' and 'btn-success' or 'btn-info')"
>
<span
class="badge pb-2"
t-esc="record.sale_planner_state.raw_value"
/>
<span
class="h3"
t-esc="record.sale_planner_rating.value"
/>
</button>
</div>
</div>
</div>
<div class="col col-lg-4 col-xl-4 pe-0">
<div class="row fw-bold">
<div
class="col-1 text-center p-0"
title="Partner"
>
<i class="fa fa-user fa-fw" />
</div>
<div class="col">
<field name="partner_ref" /> <field
name="partner_name"
/>
</div>
<div
class="col-2"
t-if="record.partner_user_id and record.partner_user_id.raw_value != record.user_id.raw_value"
>
<field
name="partner_user_id"
widget="many2one_avatar_user"
/>
</div>
</div>
<div class="row">
<div
class="col-1 text-center p-0"
title="Commercial name"
>
<i class="fa fa-home fa-fw" />
</div>
<div class="col">
<field name="partner_commercial_name" />
</div>
</div>
<div class="row">
<div class="col-12">
<div class="row">
<div
class="col-1 text-center p-0"
title="Street"
>
<i class="fa fa-map-marker fa-fw" />
</div>
<div class="col-11">
<field name="partner_street" />
</div>
</div>
</div>
<div class="col-12">
<div class="row">
<div
class="col-1 text-center p-0"
title="City"
>
<i class="fa fa-building-o fa-fw" />
</div>
<div class="col-11">
<field name="partner_city" />
</div>
</div>
</div>
</div>
<div class="row">
<div
class="col-1 text-center p-0"
title="Mobile"
>
<i class="fa fa-phone fa-fw" />
</div>
<div class="col">
<t t-if="record.partner_mobile.value">
<field
name="partner_mobile"
widget="phone"
/>
</t>
<t t-if="record.partner_contact_name.value">
(<field name="partner_contact_name" />)
</t>
</div>
</div>
</div>
<div
class="col col-sm-12 col-md-12 col-lg-6 col-xl-6 mt-xs-3"
>
<div class="row mt-2 h-100 px-md-3">
<div class="col p-1">
<button
class="btn btn-primary w-100 h-80"
title="Info"
>
<i
t-attf-class="fa fa-info fa-fw fa-2x #{record.comment.raw_value ? 'text-warning' : ''}"
/>
<div>Info</div>
</button>
</div>
<div class="col p-1">
<button
class="btn btn-primary w-100 h-80"
name="action_open_sale_order"
type="object"
title="Order"
>
<i class="fa fa-dollar fa-fw fa-2x" />
<div>Order</div>
</button>
<div
t-if="record.sale_order_subtotal.raw_value "
class="text-center mt4"
>
<field
class="text-center"
name="sale_order_subtotal"
/>
</div>
</div>
<div class="col p-1">
<button
class="btn btn-primary w-100 h-80"
name="action_open_invoices"
type="object"
title="Invoices"
>
<i
class="fa fa-pencil-square-o fa-fw fa-2x"
/>
<div class="o_stat_text">Invoices</div>
</button>
</div>
<div class="col p-1">
<button
class="btn btn-primary w-100 h-80"
name="action_open_unpaid_invoice"
type="object"
title="Due"
>
<i
class="fa fa-pencil-square-o fa-fw fa-2x"
/>
<div class="o_stat_text">Due</div>
</button>
<div
t-if="record.invoice_amount_residual.raw_value "
class="text-center mt4 text-danger fw-bold"
>
<field
class="text-center"
name="invoice_amount_residual"
/>
</div>
</div>
<div
t-if="record.sanitized_partner_mobile.value"
class="col p-1"
>
<a
role="button"
t-att-href="'https://wa.me/' + record.sanitized_partner_mobile.value"
class="btn btn-primary w-100 h-80 text-white"
title="Whatsapp"
target="_blank"
aria-label="Whatsapp"
>
<i class="fa fa-whatsapp fa-fw fa-2x" />
<div class="o_stat_text">Whatsapp</div>
</a>
</div>
<div
t-if="record.location_url.value"
class="col p-1"
>
<a
role="button"
t-att-href="'https://www.google.com/maps/search/?api=1&amp;query=' + record.location_url.value"
class="btn btn-primary w-100 h-80"
title="Location"
target="_blank"
aria-label="Location"
>
<i
class="fa fa fa-map-marker fa-fw fa-2x"
/>
<div class="o_stat_text">Location</div>
</a>
</div>
<div class="col p-1">
<button
t-attf-class="btn w-100 h-80 #{record.calendar_issue_type_id.raw_value ? 'btn-danger' : 'btn-primary'}"
name="action_open_issue"
type="object"
title="Issue"
>
<i
class="fa fa-exclamation-triangle fa-fw fa-2x"
/>
<div class="o_stat_text">Issue</div>
</button>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="action_sale_planner_calendar_event" model="ir.actions.act_window">
<field name="name">Sale planner calendar events</field>
<field name="res_model">calendar.event</field>
<field name="view_mode">kanban,tree,form</field>
<field
name="search_view_id"
ref="sale_planner_calendar.view_sale_planner_calendar_search"
/>
<field
name="context"
>{'search_default_my_event_planner': 1, 'search_default_planner_today': 1,
'search_default_state_pending': 1,
'tree_view_ref':'sale_planner_calendar.view_sale_planner_calendar_tree',
'form_view_ref':'sale_planner_calendar.view_sale_planner_calendar_form',
'kanban_view_ref':'sale_planner_calendar.view_sale_planner_calendar_kanban'
}</field>
<field name="domain">[('target_partner_id', '!=', False)]</field>
</record>
</odoo>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_sale_planner_calendar_issue_type_tree" model="ir.ui.view">
<field name="name">sale.planner.calendar.issue.type.tree</field>
<field name="model">sale.planner.calendar.issue.type</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="name" />
</tree>
</field>
</record>
<record id="action_sale_planner_calendar_issue_type" model="ir.actions.act_window">
<field name="name">Issue types</field>
<field name="res_model">sale.planner.calendar.issue.type</field>
<field name="view_mode">tree</field>
</record>
</odoo>

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<menuitem
id="menu_sale_planner_calendar_root"
name="Calendar planner"
sequence="50"
web_icon="sale_planner_calendar,static/description/icon.png"
groups="sales_team.group_sale_salesman"
/>
<menuitem
id="menu_sale_planner_calendar_summary_general"
name="Planner"
parent="menu_sale_planner_calendar_root"
sequence="10"
/>
<!-- For sales managers display summaries list instead of form view -->
<menuitem
id="menu_sale_planner_calendar_summary_today_non_commercial"
parent="menu_sale_planner_calendar_summary_general"
action="action_sale_planner_calendar_summary"
sequence="5"
groups="sales_team.group_sale_salesman_all_leads"
/>
<menuitem
id="menu_sale_planner_calendar_summary_today"
parent="menu_sale_planner_calendar_summary_general"
action="action_sale_planner_calendar_summary_today"
sequence="10"
/>
<menuitem
id="menu_sale_planner_calendar_summary"
parent="menu_sale_planner_calendar_summary_general"
action="action_sale_planner_calendar_summary"
sequence="20"
/>
<menuitem
id="menu_sale_planner_calendar_event"
parent="menu_sale_planner_calendar_summary_general"
action="action_sale_planner_calendar_event"
sequence="30"
/>
<menuitem
id="menu_sale_planner_calendar_customers"
parent="menu_sale_planner_calendar_summary_general"
action="account.res_partner_action_customer"
sequence="40"
/>
<menuitem
id="menu_calendar_event_root"
parent="menu_sale_planner_calendar_root"
name="Calendar events"
sequence="20"
/>
<menuitem
id="menu_event_calendar"
parent="menu_calendar_event_root"
action="action_calendar_event"
sequence="10"
/>
<menuitem
id="menu_calendar_event_tree_primary_only"
parent="menu_calendar_event_root"
action="calendar_event_tree_primary_only_action"
sequence="20"
/>
<menuitem
id="menu_calendar_event_configuration_root"
parent="menu_sale_planner_calendar_root"
name="Configuration"
sequence="30"
/>
<menuitem
id="menu_sale_planner_calendar_issue_type"
parent="menu_calendar_event_configuration_root"
action="action_sale_planner_calendar_issue_type"
sequence="20"
/>
<menuitem
id="menu_calendar_event_type"
parent="menu_calendar_event_configuration_root"
action="calendar.action_calendar_event_type"
sequence="30"
/>
<menuitem
id="menu_sale_planner_calendar_wizard"
parent="menu_calendar_event_configuration_root"
action="sale_planner_calendar_wizard_action"
sequence="40"
/>
<menuitem
id="menu_sale_planner_calendar_reassign_wiz"
parent="menu_calendar_event_configuration_root"
action="action_sale_planner_calendar_reassign_wiz"
sequence="50"
/>
</odoo>

View file

@ -0,0 +1,263 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_sale_planner_calendar_summary_search" model="ir.ui.view">
<field name="name">sale.planner.calendar.summary</field>
<field name="model">sale.planner.calendar.summary</field>
<field name="arch" type="xml">
<search string="Search sale planner calendar summary">
<field name="state" />
<field name="event_type_id" />
<filter
string="My summaries"
name="my_event_planner_summary"
domain="[('user_id','=', uid)]"
/>
<separator />
<filter
string="Today"
name="start_today"
domain="[('date','&gt;=', time.strftime('%%Y-%%m-%%d 00:00:00')),('date','&lt;',(datetime.date.today() + datetime.timedelta(days=1)).strftime('%%Y-%%m-%%d 00:00:00'))]"
/>
<filter
string="Tomorrow"
name="start_tomorrow"
domain="[('date','&gt;', time.strftime('%%Y-%%m-%%d 23:59:59')),('date','&lt;',(datetime.date.today() + datetime.timedelta(days=2)).strftime('%%Y-%%m-%%d 00:00:00'))]"
/>
<group string="Group By">
<filter
name="event_type_id"
string="Event type"
context="{'group_by':'event_type_id'}"
/>
</group>
</search>
</field>
</record>
<record id="view_sale_planner_calendar_summary_tree" model="ir.ui.view">
<field name="name">sale.planner.calendar.summary.tree</field>
<field name="model">sale.planner.calendar.summary</field>
<field name="arch" type="xml">
<tree>
<field name="date" optional="show" />
<field name="user_id" optional="show" />
<field name="event_type_id" optional="show" />
<field name="sale_order_subtotal" optional="show" />
<field name="state" optional="show" />
</tree>
</field>
</record>
<record id="view_sale_planner_calendar_summary_form" model="ir.ui.view">
<field name="name">sale.planner.calendar.summary.form</field>
<field name="model">sale.planner.calendar.summary</field>
<field name="arch" type="xml">
<form string="Sale planner calendar summary">
<sheet>
<div class="oe_button_box" name="button_box">
<button
class="oe_stat_button"
name="action_event_planner"
type="object"
icon="fa-home"
string="Events"
/>
<button
class="oe_stat_button"
name="action_open_sale_order"
type="object"
icon="fa-dollar"
>
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="sale_order_subtotal" />
</span>
<span class="o_stat_text">Subtotal</span>
</div>
</button>
<button
class="oe_stat_button"
name="action_open_payment_sheet"
type="object"
icon="fa-money"
string="Payment Sheet"
/>
<button
class="oe_stat_button"
name="action_open_issue"
type="object"
icon="fa-exclamation-triangle"
string="Issues"
/>
</div>
<group>
<group>
<field
name="sale_planner_calendar_event_ids"
invisible="1"
/>
<field
name="date"
attrs="{'readonly': [('sale_planner_calendar_event_ids', '!=', [])]}"
/>
<field
name="user_id"
attrs="{'readonly': [('sale_planner_calendar_event_ids', '!=', [])]}"
/>
<field name="currency_id" invisible="1" />
</group>
<group>
<field name="state" invisible="1" />
<field
name="event_type_id"
widget="selection"
attrs="{'readonly': [('sale_planner_calendar_event_ids', '!=', [])]}"
/>
<field
name="company_id"
groups="base.group_multi_company"
/>
</group>
</group>
<group string="Totals">
</group>
<div name="Totals">
<div class="row">
<div class="col" />
<div class="col h5">Total</div>
<div class="col h5">Done</div>
<div class="col h5">Effective</div>
<div class="col h5">Off planning</div>
</div>
<div class="row">
<div class="col h5">Events</div>
<div class="col"><field name="event_total_count" /></div>
<div class="col"><field name="event_done_count" /></div>
<div class="col"><field
name="event_effective_count"
/></div>
<div class="col"><field
name="event_off_planning_count"
/></div>
</div>
<hr />
<div class="row">
<div class="col" />
<div class="col h5">Documents</div>
<div class="col h5">Amount</div>
<div class="col" />
<div class="col" />
</div>
<div class="row">
<div class="col h5">Orders</div>
<div class="col"><field name="sale_order_count" /></div>
<div class="col"><field name="sale_order_subtotal" /></div>
<div class="col" />
<div class="col" />
</div>
<hr />
<div class="row">
<div class="col" />
<div class="col h5">Documents</div>
<div class="col h5">Amount</div>
<div class="col" />
<div class="col" />
</div>
<div class="row">
<div class="col h5">Payments</div>
<div class="col"><field name="payment_count" /></div>
<div class="col"><field name="payment_amount" /></div>
<div class="col" />
<div class="col" />
</div>
</div>
<group string="Comment">
<field name="comment" nolabel="1" />
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers" />
<field name="message_ids" widget="mail_thread" />
</div>
</form>
</field>
</record>
<record id="view_sale_planner_calendar_summary_kanban" model="ir.ui.view">
<field name="name">sale.planner.calendar.event.kanban</field>
<field name="model">sale.planner.calendar.summary</field>
<field name="arch" type="xml">
<kanban
class="o_kanban_mobile o_sale_planner_calendar_kanban"
default_order="date"
>
<field name="state" />
<field name="user_id" />
<field name="date" />
<field name="sale_ids" />
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card w-100 oe_kanban_global_click">
<div class="row mb4">
<div class="col-6">
<strong><span><t
t-esc="record.date.value"
/></span></strong>
</div>
<div class="col-6 text-end">
<field
name="state"
widget="label_selection"
options="{'classes': {'pending': 'info', 'cancel': 'danger', 'done': 'success'}}"
/>
</div>
</div>
<div class="row">
<div class="col-6">
<span>Total orders: <field
name="sale_order_subtotal"
/></span>
</div>
<div class="col-6">
<div class="float-end">
<button
class="btn btn-primary"
string="New order"
name="action_open_sale_order"
type="object"
>
<i class="fa fa-dollar" />
<span>Order</span>
</button>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="action_sale_planner_calendar_summary" model="ir.actions.act_window">
<field name="name">Sale planner calendar summary</field>
<field name="res_model">sale.planner.calendar.summary</field>
<field name="view_mode">tree,form,kanban</field>
<field
name="context"
>{'search_default_start_today': 1, 'search_default_my_event_planner_summary': 1}</field>
</record>
<record id="action_sale_planner_calendar_summary_today" model="ir.actions.server">
<field name="name">Event planner summary today</field>
<field name="type">ir.actions.server</field>
<field name="state">code</field>
<field
name="model_id"
ref="sale_planner_calendar.model_sale_planner_calendar_summary"
/>
<field name="code">
action = model.action_get_today_summary()
</field>
</record>
</odoo>

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import sale_invoice_payment
from . import sale_planner_calendar_reassign
from . import sale_planner_calendar_wizard

View file

@ -0,0 +1,18 @@
# Copyright 2020 Tecnativa - Carlos Dauden
# Copyright 2020 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
class SaleInvoicePaymentWiz(models.TransientModel):
_inherit = "sale.invoice.payment.wiz"
sale_planner_calendar_event_id = fields.Many2one(comodel_name="calendar.event")
def _prepare_sheet_line_values(self, invoice, amount_pay):
values = super()._prepare_sheet_line_values(invoice, amount_pay)
values.update(
{"sale_planner_calendar_event_id": self.sale_planner_calendar_event_id.id}
)
return values

View file

@ -0,0 +1,280 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# Copyright 2021 Tecnativa - Carlos Dauden
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from datetime import timedelta
from odoo import api, fields, models
class SalePlannerCalendarReassignWiz(models.TransientModel):
_name = "sale.planner.calendar.reassign.wiz"
_description = "Sale planner calendar reassign wizard"
_rec_name = "user_id"
user_id = fields.Many2one(
comodel_name="res.users",
string="Salesperson",
default=lambda self: self.env.user,
domain="[('share','=',False)]",
)
event_type_id = fields.Many2one(
comodel_name="calendar.event.type", string="Event type"
)
week_list = fields.Selection(
[
("mon", "Monday"),
("tue", "Tuesday"),
("wed", "Wednesday"),
("thu", "Thursday"),
("fri", "Friday"),
("sat", "Saturday"),
("sun", "Sunday"),
],
string="Weekday",
)
partner_id = fields.Many2one(comodel_name="res.partner")
partner_user_id = fields.Many2one(
comodel_name="res.users",
string="Partner salesperson",
domain="[('share','=',False)]",
)
new_user_id = fields.Many2one(
comodel_name="res.users",
string="New salesperson",
domain="[('share','=',False)]",
)
new_start = fields.Date()
new_end = fields.Date()
assign_new_salesperson_to_partner = fields.Boolean()
unsuscribe_old_salesperson = fields.Boolean()
line_ids = fields.One2many(
comodel_name="sale.planner.calendar.reassign.line.wiz",
inverse_name="reassign_wiz_id",
)
new_event_categ_ids = fields.Many2many(
comodel_name="calendar.event.type",
string="New tags",
help="Forces new tags for the specified period. Leave empty to keep current tags.",
)
@api.onchange(
"user_id", "event_type_id", "week_list", "partner_user_id", "partner_id"
)
def onchange_filter_values(self):
self.line_ids = False
def action_get_lines(self):
domain = [
("recurrency", "=", True),
("recurrence_id.until", ">", self.new_start or fields.Date.today()),
("is_base_recurrent_event", "=", True),
("target_partner_id", "!=", False),
]
if self.user_id:
domain.append(("user_id", "=", self.user_id.id))
if self.partner_user_id:
domain.append(("target_partner_id.user_id", "=", self.partner_user_id.id))
if self.partner_id:
domain.append(("target_partner_id", "=", self.partner_id.id))
if self.event_type_id:
domain.append(("categ_ids", "in", self.event_type_id.ids))
if self.week_list:
domain.append((self.week_list, "=", True))
calendar_events = self.env["calendar.event"].search(domain)
self.line_ids = False
for calendar_event in calendar_events:
self.env["sale.planner.calendar.reassign.line.wiz"].create(
{
"reassign_wiz_id": self.id,
"calendar_event_id": calendar_event.id,
# "event_categ_ids": [(6, 0, calendar_event.categ_ids.ids)],
"event_user_id": calendar_event.user_id.id,
"partner_id": calendar_event.target_partner_id.id,
"partner_user_id": calendar_event.partner_user_id.id,
"event_start": calendar_event.start,
"until": calendar_event.until,
}
)
def action_assign_new_values(self):
lines = self.line_ids.filtered("selected")
lines.new_user_id = self.new_user_id
lines.new_event_categ_ids = self.new_event_categ_ids
lines.selected = False
def select_all_lines(self):
self.line_ids.selected = True
def unselect_all_lines(self):
self.line_ids.selected = False
def apply(self):
# Not send emails to attendees in copy methods
if not self.env.company.sale_planner_mail_to_attendees:
self = self.with_context(no_mail_to_attendees=True, dont_notify=True)
for line in self.line_ids:
if not line.new_user_id:
continue
if self.assign_new_salesperson_to_partner:
line.partner_id.with_context(
skip_sale_planner_check=True
).user_id = line.new_user_id
# If new_start is empty only update partner user_id
if not self.new_start:
continue
old_event = line.calendar_event_id
recurrence_events = old_event.recurrence_id.calendar_event_ids
if self.new_end:
new_base_event_end = recurrence_events.filtered(
lambda ce: ce.start.date() >= self.new_end
).sorted("start")[:1]
partner_ids = (
new_base_event_end.partner_ids
+ line.event_user_id.partner_id
- line.new_user_id.partner_id
).ids
new_base_event_end.write(
{
"recurrence_update": "future_events",
"user_id": line.event_user_id.id,
"partner_ids": [
(6, False, partner_ids),
],
"is_dynamic_end_date": old_event.is_dynamic_end_date,
}
)
new_base_event_start = recurrence_events.filtered(
lambda ce: ce.start.date() >= self.new_start
).sorted("start")[:1]
partner_ids = (
new_base_event_start.partner_ids
- line.event_user_id.partner_id
+ line.new_user_id.partner_id
).ids
new_base_event_vals = {
"recurrence_update": "future_events",
"user_id": line.new_user_id.id,
"partner_ids": [
(6, False, partner_ids),
],
# Next fields has different behavior if 'self.new_end' field has a
# value
"is_dynamic_end_date": False
if self.new_end
else old_event.is_dynamic_end_date,
"unsubscribe_date": self.new_end,
}
if line.new_event_categ_ids:
new_base_event_vals["categ_ids"] = [
(6, 0, line.new_event_categ_ids.ids)
]
new_base_event_start.write(new_base_event_vals)
old_event_vals = {
"recurrence_update": "all_events",
"is_dynamic_end_date": False,
}
if self.unsuscribe_old_salesperson and not self.new_end:
old_event_vals["unsubscribe_date"] = self.new_start
old_event.write(old_event_vals)
line.update_subscriptions()
self.action_get_lines()
@api.model
def _unsubscribe(self, partner, user):
if partner.user_id != user:
partner.message_unsubscribe(partner_ids=user.partner_id.ids)
unsubscribe_domain = [
("message_partner_ids", "in", partner.ids),
("user_id", "!=", user.id),
"|",
("partner_id", "=", partner.id),
("partner_shipping_id", "=", partner.id),
]
self.env["sale.order"].search(unsubscribe_domain).message_unsubscribe(
partner_ids=user.partner_id.ids
)
self.env["account.move"].search(unsubscribe_domain).message_unsubscribe(
partner_ids=user.partner_id.ids
)
@api.model
def cron_unsubscribe(self, date=None):
if date is None:
date = fields.Date.today()
events = self.env["calendar.event"].search(
[
("unsubscribe_date", "<=", date),
]
)
for event in events:
self._unsubscribe(event.target_partner_id, event.user_id)
events.unsubscribe_date = False
class SalePlannerCalendarReassignLineWiz(models.TransientModel):
_name = "sale.planner.calendar.reassign.line.wiz"
_description = "Sale planner calendar reassign lines wizard"
reassign_wiz_id = fields.Many2one(comodel_name="sale.planner.calendar.reassign.wiz")
selected = fields.Boolean()
calendar_event_id = fields.Many2one(comodel_name="calendar.event", readonly=True)
event_categ_ids = fields.Many2many(
comodel_name="calendar.event.type", related="calendar_event_id.categ_ids"
)
partner_id = fields.Many2one(comodel_name="res.partner", readonly=True)
partner_user_id = fields.Many2one(
comodel_name="res.users",
string="Partner salesperson",
)
event_user_id = fields.Many2one(
comodel_name="res.users",
string="Planner salesperson",
readonly=True,
domain="[('share','=',False)]",
)
new_user_id = fields.Many2one(
comodel_name="res.users",
domain="[('share','=',False)]",
)
new_event_categ_ids = fields.Many2many(
comodel_name="calendar.event.type",
string="New tags",
help="Forces new tags for the specified period. Leave empty to keep current tags.",
)
event_start = fields.Datetime(readonly=True)
until = fields.Datetime(readonly=True)
def update_subscriptions(self):
for line in self:
backward_date = fields.Date.today() - timedelta(
days=self.env.company.susbscriptions_backward_days
)
sale_orders = self.env["sale.order"].search(
[
("date_order", ">=", backward_date),
("partner_id", "=", line.partner_id.id),
("user_id", "!=", line.new_user_id.id),
]
)
invoices = (
self.env["account.move"]
.sudo()
.search(
[
("move_type", "in", ["out_invoice", "out_refund"]),
("user_id", "!=", line.new_user_id.id),
"|",
("invoice_date", ">=", backward_date),
("payment_state", "!=", "paid"),
"|",
("partner_id", "=", line.partner_id.id),
("partner_shipping_id", "=", line.partner_id.id),
]
)
)
for records in [line.partner_id, sale_orders, invoices]:
records.message_subscribe(partner_ids=line.new_user_id.partner_id.ids)
self.reassign_wiz_id.cron_unsubscribe()

View file

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="ir.ui.view" id="view_sale_planner_calendar_reassign_wiz">
<field name="name">sale.planner.calendar.reassign.wiz</field>
<field name="model">sale.planner.calendar.reassign.wiz</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group string="Filter values">
<field name="user_id" />
<field name="partner_user_id" />
<field name="partner_id" />
<field name="event_type_id" widget="selection" />
<field name="week_list" />
<div />
<div class="ml-auto mt16">
<button
name="action_get_lines"
type="object"
string="Fill events from filter values"
icon="fa-refresh"
class="btn-light"
attrs="{'invisible': [('line_ids', '=', [])]}"
/>
<button
name="action_get_lines"
type="object"
string="Fill events from filter values"
class="btn-primary"
icon="fa-refresh"
attrs="{'invisible': [('line_ids', '!=', [])]}"
/>
</div>
</group>
<group string="New values" col="1">
<group>
<field name="new_start" />
<field name="new_end" />
</group>
<p class="text-muted">
If the new end field is set, in addition to creating the recurring event assigned to the new commercial,
a new recurring event will be created from the end date associated with the current commercial.
</p>
</group>
<group />
<group string="Optional actions" col="1">
<group>
<field name="assign_new_salesperson_to_partner" />
<field
name="unsuscribe_old_salesperson"
attrs="{'invisible': [('new_end', '!=', False)]}"
/>
</group>
</group>
</group>
<group string="Events to update" col="1">
<group>
<group>
<field name="new_user_id" />
</group>
<group>
<field
name="new_event_categ_ids"
widget="many2many_tags"
/>
</group>
</group>
<div>
<button
class="oe_link"
name="select_all_lines"
type="object"
string="Select all lines"
/>
<button
class="oe_link"
name="unselect_all_lines"
type="object"
string="Unselect all lines"
/>
<button
class="oe_link ml32"
name="action_assign_new_values"
type="object"
string="Assign new values to selected lines"
/>
</div>
<field name="line_ids" nolabel="1">
<tree
editable="top"
create="false"
decoration-info="new_user_id"
>
<field name="selected" widget="boolean_toggle" />
<field name="calendar_event_id" optional="show" />
<field
name="event_categ_ids"
widget="many2many_tags"
options="{'color_field': 'color'}"
optional="hide"
/>
<field name="partner_id" />
<field name="event_user_id" optional="hide" />
<field name="partner_user_id" optional="hide" />
<field name="event_start" optional="hide" />
<field name="until" optional="hide" />
<field name="new_user_id" />
<field
name="new_event_categ_ids"
widget="many2many_tags"
optional="hide"
/>
</tree>
</field>
</group>
<div>
<p class="text-muted">
The changes will be applied to event records with new commercial user filled.
</p>
<button
name="apply"
string="Apply changes"
class="btn-primary"
type="object"
/>
<button string="Cancel" class="oe_link" special="cancel" />
</div>
</sheet>
</form>
</field>
</record>
<record
id="action_sale_planner_calendar_reassign_wiz"
model="ir.actions.act_window"
>
<field name="name">Reassignment of salesperson</field>
<field name="res_model">sale.planner.calendar.reassign.wiz</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
</record>
</odoo>

View file

@ -0,0 +1,74 @@
# Copyright 2021 Tecnativa - Sergio Teruel
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class SalePlannerCalendarWizard(models.TransientModel):
_name = "sale.planner.calendar.wizard"
_description = "Sale planner calendar Wizard"
user_id = fields.Many2one(
comodel_name="res.users",
string="Salesperson",
default=lambda self: self.env.user,
domain="[('share','=',False)]",
)
event_type_id = fields.Many2one(
comodel_name="calendar.event.type", string="Event type"
)
week_list = fields.Selection(
[
("mon", "Monday"),
("tue", "Tuesday"),
("wed", "Wednesday"),
("thu", "Thursday"),
("fri", "Friday"),
("sat", "Saturday"),
("sun", "Sunday"),
],
string="Weekday",
)
# Special hack One2many field to manage and save records directly
calendar_event_ids = fields.One2many(
comodel_name="calendar.event",
compute="_compute_calendar_event_ids",
readonly=False,
)
@api.depends("user_id", "event_type_id", "week_list")
def _compute_calendar_event_ids(self):
for rec in self:
domain = [
("recurrency", "=", True),
("recurrence_id.until", ">", fields.Date.today()),
("is_base_recurrent_event", "=", True),
("target_partner_id", "!=", False),
]
if rec.user_id:
domain.append(("user_id", "=", rec.user_id.id))
if rec.event_type_id:
domain.append(("categ_ids", "in", rec.event_type_id.ids))
if rec.week_list:
domain.append(("recurrence_id." + rec.week_list, "=", True))
rec.calendar_event_ids = (
self.env["calendar.event"].search(domain).sorted("hour")
)
# TODO: Remove when control_panel_hidden works
@api.depends("user_id", "event_type_id")
def name_get(self):
result = []
for wiz in self:
name = "{} - {}".format(wiz.user_id.name, wiz.event_type_id.name or "Plan")
result.append((wiz.id, name))
return result
def apply(self):
pass
def write(self, vals):
# Not send emails to attendees for update events
if not self.env.company.sale_planner_mail_to_attendees:
self = self.with_context(no_mail_to_attendees=True, dont_notify=True)
return super().write(vals)

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record model="ir.ui.view" id="view_sale_planner_calendar_wizard">
<field name="name">sale.planner.calendar.wizard</field>
<field name="model">sale.planner.calendar.wizard</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="user_id" />
<field name="event_type_id" widget="selection" />
<field name="week_list" />
</group>
<field name="calendar_event_ids">
<tree
editable="top"
default_order="hour"
create="false"
delete="false"
>
<field name="name" optional="show" />
<field name="allday" invisible="1" />
<field name="hour" widget="float_time" readonly="0" />
<field name="target_partner_id" readonly="1" />
<field
name="categ_ids"
widget="many2many_tags"
optional="hide"
readonly="1"
options="{'color_field': 'color'}"
/>
<field name="start" optional="hide" readonly="1" />
<field name="stop" optional="hide" readonly="1" />
<field name="until" optional="hide" readonly="1" />
<field
name="duration"
optional="hide"
widget="float_time"
/>
</tree>
</field>
<button
name="apply"
string="OK"
class="btn-primary"
type="object"
/>
<button string="Cancel" class="btn-default" special="cancel" />
</sheet>
</form>
</field>
</record>
</odoo>