From 3df9f5d689fbf71c5dd3ec5ba586df0d022fce91 Mon Sep 17 00:00:00 2001 From: abd-msyukyu-odoo Date: Thu, 24 Jun 2021 08:57:37 +0000 Subject: [PATCH] [IMP] crm: add a forecast feature (kanban, pivot, graph, list) * Currently located in CRM as it is its only use. After Owl-ification of Web Client it will probably be added as a core feature. * Objectives: - Kanban view: - Kanban groups on a specific date/time field should be contiguous and show a short term vision. Empty groups should be shown -> FillTemporalService -> ForecastKanbanModel -> context.forecast_field - Add the next period (i.e. month) with one click in the kanban view -> ForecastKanbanRenderer -> ForecastKanbanController -> ForecastColumnQuickCreate - Store specific time ranges for each granularity (year, month, ...), so that when the user switches back to one his progress will be recovered. (reset on page refresh) -> FillTemporalService - Graph, Kanban, List, Pivot views: - Filter out data from the past (previous periods), while keeping every records from the current period even if we are in the middle of it -> ForecastModelExtension -> context.forecast_filter -> context.forecast_field * context key `forecast_field`: - Used to set the date/time field name, on which the forecast logic should be applied - This key will be used by : a js model (for a view) / ForecastModelExtension / FillTemporalService * ForecastModelExtension and context key `forecast_filter`: This extension will use a filter, the groupBy value, and the context.forecast_field to apply a custom domain constraint. This domain constraint can also be applied by default with the FillTemporalService, but it was decided to extract it to a filter for the user to be able to disable it, to see "old forgotten records" and update them if necessary. In other words, this filter enables or disables the first bound of the domain for the read group. - Filter "Forecast": This filter should set the context key `forecast_filter:1`, in order to only get future records starting from the start of the period containing "now". example: now: 2021-04-26 groupBy: month => all records starting from 2021-04-01 match the filter - If none of the groupBy values are the forecast_field, filters the records on the forecast_field starting from "now" * FillTemporalService: This service will be used to generate or recover `FillTemporalPeriod`. Depending on the configuration, it will return an instance handling a certain `forecast_field`, with a certain `granularity` (hour, day, week, month, quarter, year), for a certain `modelName`. This can be used in multiple views to always get the same object, if it concerns the same model, the same field and the same granularity. The generated `FillTemporalPeriod` object is used to compute the final domain and context to be provided to the backend `read_group`. The objective was to limit the amount of desired groups regarding a specific time period (cycle). Here are the cycles depending on the granularity: granularity - cycle ------------------------------- hour - one day day - one week week - one week month - one year quarter - one year year - one year In order to get a minimal amount of groups after a read_group, we can provide an integer in the configuration : `min_groups`, which will guarantee this amount of groups, regardless of the rest of the configuration The default maximum amount of groups returned depends on the end of the specific cycle reached after guaranteeing `min_groups` We can always alter the result by directly modifying the `start` and `end` properties of the `FillTemporalPeriod`, using the dedicated methods to do so. Configuration of the domain with the keys `force[Start|End]Bound` - true: applies a lower|upper bound for the domain, which means that the subsequent read_group will search for groups [from the start|until the end] of the FillTemporalPeriod - false: no domain constraint applied, which means that at least every groups with records in the database will be returned Configuration of the context with the keys `forceFilling[From|To]` - true: set fill_temporal.fill_[from|to], which means that at least every groups [from the start|until the end] of the `FillTemporalPeriod` will be returned as contiguous groups by a read_group - false: does not set fill_temporal.fill_[from|to], which means that read_group will only return groups from|until the first|last one with at least one record The `expand` method can be used to add one interval to the end of the `FillTemporalPeriod` depending on the granularity (add one group) * any "forecast" view : - should define a `forecast_field` in the context - should use the `forecast_model_extension` with the searchModel to be able to filter "future" records correctly - could make use of the `FillTemporalService` to maintain a consistent domain and context when grouping by the desired date field. The domain and context are to be applied on __load and __reload calls in the MODEL, on the provided domain and context, to further filter the groups returned by a read_group. * forecast views: graph, kanban, list, pivot * kanban uses: forecast_model_extension, fill_temporal_service has a custom `column_quick_create` limited to the forecast_field which allows to add the next group (period depends on granularity) works with the sample_server even thought the domain and the context are modified during the __load/__reload processes. * graph, pivot, list use: forecast_model_extension Task-ID: 2243913 PR odoo/odoo#69380 --- addons/crm/__manifest__.py | 5 + .../src/js/forecast/fill_temporal_service.js | 318 ++++++++++++++++++ .../src/js/forecast/forecast_controllers.js | 35 ++ .../forecast_kanban_column_quick_create.js | 48 +++ .../js/forecast/forecast_model_extension.js | 116 +++++++ .../static/src/js/forecast/forecast_models.js | 170 ++++++++++ .../src/js/forecast/forecast_renderers.js | 31 ++ .../static/src/js/forecast/forecast_views.js | 96 ++++++ addons/crm/static/src/xml/forecast_kanban.xml | 12 + .../crm/static/tests/forecast_kanban_tests.js | 258 ++++++++++++++ 10 files changed, 1089 insertions(+) create mode 100644 addons/crm/static/src/js/forecast/fill_temporal_service.js create mode 100644 addons/crm/static/src/js/forecast/forecast_controllers.js create mode 100644 addons/crm/static/src/js/forecast/forecast_kanban_column_quick_create.js create mode 100644 addons/crm/static/src/js/forecast/forecast_model_extension.js create mode 100644 addons/crm/static/src/js/forecast/forecast_models.js create mode 100644 addons/crm/static/src/js/forecast/forecast_renderers.js create mode 100644 addons/crm/static/src/js/forecast/forecast_views.js create mode 100644 addons/crm/static/src/xml/forecast_kanban.xml create mode 100644 addons/crm/static/tests/forecast_kanban_tests.js diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py index 395bf2ee09b..9282c7991f0 100644 --- a/addons/crm/__manifest__.py +++ b/addons/crm/__manifest__.py @@ -71,9 +71,13 @@ 'application': True, 'auto_install': False, 'assets': { + 'web.assets_qweb': [ + 'crm/static/src/xml/forecast_kanban.xml', + ], 'web.assets_backend': [ 'crm/static/src/js/crm_form.js', 'crm/static/src/js/crm_kanban.js', + 'crm/static/src/js/forecast/*', 'crm/static/src/js/systray_activity_menu.js', 'crm/static/src/js/tours/crm.js', 'crm/static/src/scss/crm.scss', @@ -84,6 +88,7 @@ ], 'web.qunit_suite_tests': [ 'crm/static/tests/mock_server.js', + 'crm/static/tests/forecast_kanban_tests.js', 'crm/static/tests/crm_rainbowman_tests.js', ], } diff --git a/addons/crm/static/src/js/forecast/fill_temporal_service.js b/addons/crm/static/src/js/forecast/fill_temporal_service.js new file mode 100644 index 00000000000..f5fac01bd2f --- /dev/null +++ b/addons/crm/static/src/js/forecast/fill_temporal_service.js @@ -0,0 +1,318 @@ +/** @odoo-module */ +import AbstractService from 'web.AbstractService'; +import core from 'web.core'; + +/** + * Configuration depending on the granularity: + * @param {function} startOf function to get the start moment of the period from a moment + * @param {int} cycle amount of 'granularity' periods constituting a cycle. The cycle duration + * is arbitrary for each granularity: + * cycle --- granularity + * ___________________________ + * 1 day hour + * 1 week day + * 1 week week # there is no greater time period that takes an integer amount of weeks + * 1 year month + * 1 year quarter + * 1 year year # we are not using a greater time period in Odoo (yet) + * @param {int} cyclePos function to get the position (index) in the cycle from a moment. + * {1} is the first index. {+1} is used for functions which have an index + * starting from 0, to standardize between granularities. + */ +const GRANULARITY_TABLE = { + hour: { + startOf: x => x.startOf('hour'), + cycle: 24, + cyclePos: x => x.hour() + 1, + }, + day: { + startOf: x => x.startOf('day'), + cycle: 7, + cyclePos: x => x.isoWeekday(), + }, + week: { + startOf: x => x.startOf('isoWeek'), + cycle: 1, + cyclePos: x => 1, + }, + month: { + startOf: x => x.startOf('month'), + cycle: 12, + cyclePos: x => x.month() + 1, + }, + quarter: { + startOf: x => x.startOf('quarter'), + cycle: 4, + cyclePos: x => x.quarter(), + }, + year: { + startOf: x => x.startOf('year'), + cycle: 1, + cyclePos: x => 1, + }, +}; + +/** + * configuration depending on the time type: + * @param {string} format moment format to display this type as a string + * @param {string} minGranularity granularity of the smallest time interval used in Odoo for this + * type + */ +const FIELD_TYPE_TABLE = { + date: { + format: "YYYY-MM-DD", + minGranularity: "day", + }, + datetime: { + format: "YYYY-MM-DD HH:mm:ss", + minGranularity: "second", + } +}; + +/** + * fill_temporal period: + * Represents a specific date/time range for a specific model, field and granularity. + * + * It is used to add new domain and context constraints related to a specific date/time + * field, in order to configure the _read_group_fill_temporal (see core models.py) + * method. It will be used when we want to get continuous groups in chronological + * order in a specific date/time range. + */ +class FillTemporalPeriod { + /** + * This constructor is meant to be used only by the FillTemporalService (see below) + * + * @param {string} modelName directly taken from model.loadParams.modelName. + * this is the `res_model` from the action (i.e. `crm.lead`) + * @param {Object} field a dictionary with keys "name" and "type". + * name: Name of the field on which the fill_temporal should apply + * (i.e. 'date_deadline') + * type: 'date' or 'datetime' + * @param {string} granularity can either be : hour, day, week, month, quarter, year + * @param {integer} minGroups minimum amount of groups to display, regardless of other + * constraints + */ + constructor(modelName, field, granularity, minGroups) { + this.modelName = modelName; + this.field = field; + this.granularity = granularity || "month"; + this.setMinGroups(minGroups); + + this._computeStart(); + this._computeEnd(); + } + /** + * Compute the moment for the start of the period containing "now" + * + * @private + */ + _computeStart() { + this.start = GRANULARITY_TABLE[this.granularity].startOf(moment()); + } + /** + * Compute the moment for the end of the fill_temporal period. This bound is exclusive. + * The fill_temporal period is the number of [granularity] from [start] to the end of the + * [cycle] reached after adding [minGroups] + * i.e. we are in october 2020 : + * [start] = 2020-10-01 + * [granularity] = 'month', + * [cycle] = 12 + * [minGroups] = 4, + * => fillTemporalPeriod = 15 months (until end of december 2021) + * + * @private + */ + _computeEnd() { + const cycle = GRANULARITY_TABLE[this.granularity].cycle; + const cyclePos = GRANULARITY_TABLE[this.granularity].cyclePos(this.start); + /** + * fillTemporalPeriod formula explanation : + * We want to know how many steps need to be taken from the current position until the end + * of the cycle reached after guaranteeing minGroups positions. Let's call this cycle (C). + * + * (1) compute the steps needed to reach the last position of the current cycle, from the + * current position: + * {cycle - cyclePos} + * + * (2) ignore {minGroups - 1} steps from the position reached in (1). Now, the current + * position is somewhere in (C). One step from minGroups is reserved to reach the first + * position after (C), hence {-1} + * + * (3) compute the additional steps needed to reach the last position of (C), from the + * position reached in (2): + * {cycle - (minGroups - 1) % cycle} + * + * (4) combine (1) and (3), the sum should not be greater than a full cycle (-> truncate): + * {(2 * cycle - (minGroups - 1) % cycle - cyclePos) % cycle} + * + * (5) add minGroups! + */ + const fillTemporalPeriod = (2 * cycle - (this.minGroups - 1) % cycle - cyclePos) % cycle + this.minGroups; + this.end = moment(this.start).add(fillTemporalPeriod, `${this.granularity}s`); + this.computedEnd = true; + } + /** + * The server needs a date/time in UTC, but we don't want a day shift in case + * of dates, even if the date is not in UTC + * + * @param {moment} bound the moment to be formatted (this.start or this.end) + */ + _getFormattedServerDate(bound) { + if (bound.isUTC() || this.field.type === "date") { + return bound.format(FIELD_TYPE_TABLE[this.field.type].format); + } else { + return moment.utc(bound).format(FIELD_TYPE_TABLE[this.field.type].format); + } + } + /** + * @param {Object} configuration + * @param {Array[]} [domain] + * @param {boolean} [forceStartBound=true] whether this.start moment must be used as a domain + * constraint to limit read_group results or not + * @param {boolean} [forceEndBound=true] whether this.end moment must be used as a domain + * constraint to limit read_group results or not + * @returns {Array[]} new domain + */ + getDomain({domain, forceStartBound=true, forceEndBound=true}) { + if (!forceEndBound && !forceStartBound) { + return domain; + } + const originalDomain = domain.length ? ['&', ...domain] : []; + const defaultDomain = ['|', [this.field.name, '=', false]]; + const linkDomain = forceStartBound && forceEndBound ? ['&'] : []; + const startDomain = !forceStartBound ? [] : [ + [this.field.name, '>=', this._getFormattedServerDate(this.start)]]; + const endDomain = !forceEndBound ? [] : [ + [this.field.name, '<', this._getFormattedServerDate(this.end)]]; + return [...originalDomain, ...defaultDomain, ...linkDomain, ...startDomain, ...endDomain]; + } + /** + * The default value of forceFillingTo is false when this.end is the + * computed one, and true when it is manually set. This is because the default value of + * this.end is computed without any knowledge of the existing data, and as such, we only + * want to get continuous groups until the last group with data (no need to force until + * this.end). On the contrary, when we set this.end, this means that we want groups until + * that date. + * + * @param {Object} configuration + * @param {Object} [context] + * @param {boolean} [forceFillingFrom=true] fill_temporal must apply from: + * true: this.start + * false: the first group with at least one record + * @param {boolean} [forceFillingTo=!this.computedEnd] fill_temporal must apply until: + * true: this.end + * false: the last group with at least one record + * @returns {Object} new context + */ + getContext({context, forceFillingFrom=true, forceFillingTo=!this.computedEnd}) { + const fillTemporal = { + min_groups: this.minGroups, + }; + if (forceFillingFrom) { + fillTemporal.fill_from = this._getFormattedServerDate(this.start); + } + if (forceFillingTo) { + fillTemporal.fill_to = this._getFormattedServerDate( + moment(this.end).subtract(1, FIELD_TYPE_TABLE[this.field.type].minGranularity) + ); + } + context = {...context, fill_temporal: fillTemporal}; + return context; + } + /** + * @param {integer} minGroups minimum amount of groups to display, regardless of other + * constraints + */ + setMinGroups(minGroups) { + this.minGroups = minGroups || 1; + } + /** + * sets the end of the period to the desired moment. It must be greater + * than start. Changes the default behavior of getContext forceFillingTo + * (becomes true instead of false) + * + * @param {moment} end + */ + setEnd(end) { + this.end = moment.max(this.start, end); + this.computedEnd = false; + } + /** + * sets the start of the period to the desired moment. It must be smaller than end + * + * @param {moment} start + */ + setStart(start) { + this.start = moment.min(this.end, start); + } + /** + * Adds one "granularity" period to [this.end], to expand the current fill_temporal period + */ + expand() { + this.setEnd(this.end.add(1, `${this.granularity}s`)); + } +} + +/** + * fill_temporal Service + * + * This service will be used to generate or retrieve fill_temporal periods + * + * A specific fill_temporal period configuration will always refer to the same instance + * unless forceRecompute is true + */ + const FillTemporalService = AbstractService.extend({ + /** + * @override + */ + start() { + this._super.apply(...arguments); + this._fillTemporalPeriods = {}; + }, + /** + * Get a fill_temporal period according to the configuration. + * The default initial fill_temporal period is the number of [granularity] from [start] + * to the end of the [cycle] reached after adding [minGroups] + * i.e. we are in october 2020 : + * [start] = 2020-10-01 + * [granularity] = 'month', + * [cycle] = 12 (one year) + * [minGroups] = 4, + * => fillTemporalPeriod = 15 months (until the end of december 2021) + * Once created, a fill_temporal period for a specific configuration will be stored + * until requested again. This allows to manipulate the period and store the changes + * to it. This also allows to keep the configuration when switching to another view + * + * @param {Object} configuration + * @param {string} [modelName] directly taken from model.loadParams.modelName. + * this is the `res_model` from the action (i.e. `crm.lead`) + * @param {Object} [field] a dictionary with keys "name" and "type". + * @param {string} [field.name] name of the field on which the fill_temporal should apply + * (i.e. 'date_deadline') + * @param {string} [field.type] date field type: 'date' or 'datetime' + * @param {string} [granularity] can either be : hour, day, week, month, quarter, year + * @param {integer} [minGroups=4] optional minimal amount of desired groups + * @param {boolean} [forceRecompute=false] optional whether the fill_temporal period should be + * reinstancied + * @returns {FillTemporalPeriod} + */ + getFillTemporalPeriod({modelName, field, granularity, minGroups=4, forceRecompute=false}) { + if (!(modelName in this._fillTemporalPeriods)) { + this._fillTemporalPeriods[modelName] = {}; + } + if (!(field.name in this._fillTemporalPeriods[modelName])) { + this._fillTemporalPeriods[modelName][field.name] = {}; + } + if (!(granularity in this._fillTemporalPeriods[modelName][field.name]) || forceRecompute) { + this._fillTemporalPeriods[modelName][field.name][granularity] = + new FillTemporalPeriod(modelName, field, granularity, minGroups); + } else if (this._fillTemporalPeriods[modelName][field.name][granularity].minGroups != minGroups) { + this._fillTemporalPeriods[modelName][field.name][granularity].setMinGroups(minGroups); + } + return this._fillTemporalPeriods[modelName][field.name][granularity]; + } +}); + +core.serviceRegistry.add('fillTemporalService', FillTemporalService); + +export default FillTemporalService; diff --git a/addons/crm/static/src/js/forecast/forecast_controllers.js b/addons/crm/static/src/js/forecast/forecast_controllers.js new file mode 100644 index 00000000000..bdac0ccad2e --- /dev/null +++ b/addons/crm/static/src/js/forecast/forecast_controllers.js @@ -0,0 +1,35 @@ +/** @odoo-module */ +import KanbanController from 'web.KanbanController'; + +const ForecastKanbanController = KanbanController.extend({ + custom_events: _.extend({}, KanbanController.prototype.custom_events, { + forecast_kanban_add_column: '_onAddColumnForecast', + }), + + /** + * Expand the fill_temporal period after the ForecastColumnQuickCreate has been used, then + * reload the view to refetch updated data + * + * @private + * @param {OdooEvent} ev + */ + _onAddColumnForecast(ev) { + ev.stopPropagation(); + this.call('fillTemporalService', 'getFillTemporalPeriod', { + modelName: this.model.loadParams.modelName, + field: { + name: this.model.forecast_field, + type: this.model.loadParams.fields[this.model.forecast_field].type, + }, + granularity: this.model.granularity, + }).expand(); + this.mutex.exec(() => this.update( + { groupBy: [`${this.model.forecast_field}:${this.model.granularity}`] }, + { reload: true } + )); + }, +}); + +export { + ForecastKanbanController, +}; diff --git a/addons/crm/static/src/js/forecast/forecast_kanban_column_quick_create.js b/addons/crm/static/src/js/forecast/forecast_kanban_column_quick_create.js new file mode 100644 index 00000000000..cc207962e7a --- /dev/null +++ b/addons/crm/static/src/js/forecast/forecast_kanban_column_quick_create.js @@ -0,0 +1,48 @@ +/** @odoo-module */ +import Widget from 'web.Widget'; +import { _t } from 'web.core'; + +/** + * Widget to handle events related to the Add button in + * ForecastKanban views. The view is supposed to automatically add + * the next column (chronological order), and use the + * FillTemporalService which will handle the covered fill_temporal period + * to know which column to add. + */ +const ForecastColumnQuickCreate = Widget.extend({ + template: 'KanbanView.ForecastColumnQuickCreate', + events: { + 'click .o_quick_create_folded': '_onAddColumnClicked', + }, + + /** + * @override + * @param {Object} options + * @param {string} [options.addColumnLabel] displayed label + * (should be the granularity of a date/datetime groupBy) + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.addColumnLabel = _.str.sprintf(_t('Add next %s'), options.addColumnLabel); + }, + + /** + * Notify the environment to add a column + * + * @private + */ + _addColumn: function () { + this.trigger_up('forecast_kanban_add_column', {}); + }, + + /** + * @private + * @param {MouseEvent} event + */ + _onAddColumnClicked: function (event) { + event.stopPropagation(); + this._addColumn(); + }, +}); + +export default ForecastColumnQuickCreate; diff --git a/addons/crm/static/src/js/forecast/forecast_model_extension.js b/addons/crm/static/src/js/forecast/forecast_model_extension.js new file mode 100644 index 00000000000..0c6401317c8 --- /dev/null +++ b/addons/crm/static/src/js/forecast/forecast_model_extension.js @@ -0,0 +1,116 @@ +/** @odoo-module */ +import ActionModel from 'web.ActionModel'; + +/** + * This file contains the logic behind a special "Forecast" filter. + * Any such filter should set the context key {forecast_filter: 1} + * Another context key must also be set for the view using this model extension: + * {forecast_field: "date_field_name"}, which represents the date/time field on which + * the forecast should be applied + * + * The main purpose is to be able to modify the domain depending on the groupby granularity + * when the field is a `date/time` field and is the `forecast_field`. The domain should filter + * records from the period (granularity) containing "now", or those where the forecast_field + * is not set. + * example: + * today: 2021-04-21 + * granularity: month + * field name: date_field + * -> the domain would be: ['&', ['date_field', '=', false], ['date_field', '>=', '2021-04-01']] + */ +const DATE_FORMAT = { + datetime: "YYYY-MM-DD HH:mm:ss", + date: "YYYY-MM-DD", +}; +class ForecastModelExtension extends ActionModel.Extension { + /** + * @override + * @returns {any} + */ + get(property) { + switch (property) { + case "domain": return this.getDomain(); + default: return super.get(...arguments); + } + } + + /** + * @override + */ + prepareState() { + super.prepareState(...arguments); + Object.assign(this.state, { + forecastField: null, // {string} forecast_field from the context (name) + forecastFieldType: null, // {string} forecast_field type (date or datetime) + forecastFilter: null, // {boolean} if the "Forecast" filter is active + forecastStart: null, // {string} limiting bound of the filter (if active) + // -> starting date before which records are filtered out + }); + } + + /** + * The state needs to be recomputed each time the parent model initiates a load action. + * + * @override + * @returns {Promise} + */ + callLoad() { + this._computeState(); + return super.callLoad(...arguments); + } + + /** + * Adds a domain constraint to only get records from the start of the period containing "now", + * only if the "Forecast" filter is active + * + * @returns {Array[]} + */ + getDomain() { + return !this.state.forecastFilter ? null : + ['|', [this.state.forecastField, '=', false], + [this.state.forecastField, '>=', this.state.forecastStart] + ]; + } + + /** + * The state is mostly dependent on 2 context keys : + * forecast_field -> is a prerequisite for this extension to work + * forecast_filter -> a filter which applies this context key should be present and active in + * order to get the modified domain + * If the forecast_field is present in the groupby, the related granularity is used + * If no granularity is specified, or if there is no groupby, the default is "month" + * If there is a groupby, but not the forecast_field, "day" is used (the filter will get data + * from 00:00:00 today, browser time) + * + * @private + */ + _computeState() { + this.prepareState(); + if (!this.config.context.forecast_field) { return; } + this.state.forecastField = this.config.context.forecast_field; + const format = DATE_FORMAT[this.config.fields[this.state.forecastField].type]; + const filters = this.config.get('filters').flat(); + this.state.forecastFilter = !!filters.filter(f => f.isActive && f.context && + f.context.forecast_filter).length; + const groupBy = this.config.get('groupBy').flat(); + const forecastGroupBys = groupBy.filter(gb => gb.includes(this.state.forecastField)); + let granularity = "month"; + if (forecastGroupBys.length > 0) { + [this.state.forecastField, granularity] = [...forecastGroupBys[0].split(":"), granularity]; + } else if (groupBy.length) { + // there is a groupBy, but it is not the forecast_field + granularity = "day"; + } + let startMoment = moment().startOf(granularity); + // The server needs a date/time in UTC, but to avoid a day shift in case + // of date, we only need to consider it for datetime fields + if (this.config.fields[this.state.forecastField].type === "datetime") { + startMoment = moment.utc(startMoment); + } + this.state.forecastStart = startMoment.format(format); + } +} + +ActionModel.registry.add("Forecast", ForecastModelExtension, 20); + +export default ForecastModelExtension; diff --git a/addons/crm/static/src/js/forecast/forecast_models.js b/addons/crm/static/src/js/forecast/forecast_models.js new file mode 100644 index 00000000000..2ce27cca5e5 --- /dev/null +++ b/addons/crm/static/src/js/forecast/forecast_models.js @@ -0,0 +1,170 @@ +/** @odoo-module */ +import KanbanModel from 'web.KanbanModel'; + +const ForecastKanbanModel = KanbanModel.extend({ + /** + * Checks whether to apply the forecast logic to a load or a reload, depending on the groupby + * and the forecast_field. Sets the current granularity if the forecast applies + * + * @private + * @param {Object} element what to load (params) / reload (localData from id) + * @param {Object} params optional reload params + * @returns {boolean} + */ + _isForecast(element, params={}) { + let groupby, granularity; + if (!element) { + return false; + } + if ("groupBy" in params) { + [groupby, granularity] = params.groupBy[0].split(':'); + } else if (element.groupedBy.length != 0) { + [groupby, granularity] = element.groupedBy[0].split(':'); + } else if (element.groupBy.length != 0) { + [groupby, granularity] = element.groupBy[0].split(':'); + } + granularity = granularity || "month"; + if (!this.forecast_field || groupby != this.forecast_field) { + return false; + } + this.granularity = granularity; + return true; + }, + + /** + * At every __load/__reload, we have to check the range of the last group received from the + * read_group, and update the fillTemporalPeriod from the FillTemporalService accordingly + * + * @private + */ + _updateFillTemporalPeriodEnd(fillTemporalPeriod) { + let lastGroup = this.get(this.handle).data.filter(group => group.value).slice(-1)[0]; + if (lastGroup) { + lastGroup = this.localData[lastGroup.id]; + fillTemporalPeriod.setEnd(moment.utc(lastGroup.range[this.forecast_field].to)); + } + }, + + /** + * Applies the forecast logic to the domain and context if needed before the read_group. + * After the read_group, checks the end date of the last displayed group in order to be able to + * add the next group with the ForecastColumnQuickCreate widget + * + * @private + * @override + */ + async __load(params) { + this.loadDomain = params.domain; + this.loadContext = params.context; + this.forecast_field = params.context.forecast_field; + if (!this._isForecast(params)) { + return this._super(...arguments); + } + this.minGroups = + params.context.fill_temporal && params.context.fill_temporal.min_groups || undefined; + const fillTemporalPeriod = this.call('fillTemporalService', 'getFillTemporalPeriod', { + modelName: params.modelName, + field: { + name: this.forecast_field, + type: params.fields[this.forecast_field].type, + }, + granularity: this.granularity, + minGroups: this.minGroups, + forceRecompute: true, + }); + params.domain = fillTemporalPeriod.getDomain({ + domain: this.loadDomain, + forceStartBound: false + }); + params.context = fillTemporalPeriod.getContext({ context: this.loadContext }); + this.handle = await this._super(...arguments); + this._updateFillTemporalPeriodEnd(fillTemporalPeriod); + return this.handle; + }, + + /** + * In order to display the sample data, the context and domain need to be the same as before a + * reload. If the user switches to another view (list, pivot, ...) and comes back without + * adding any record or modifying the search bar, we still want to display the sample data, but + * the actual domain and context won't be the same because load and reload are modifying them. + * + * As such, we will only compare the original domain and context (without the use of the + * FillTemporalService) to determine whether the sample data should be enabled or not. + * + * The behavior for timeRanges and groupBy is unchanged from the original + * + * @private + * @override + * @param {Object} [params={}] + * @param {Object} [params.context] + * @param {Array[]} [params.domain] + * @param {Object} [params.timeRanges] + * @param {string[]} [params.groupBy] + * @returns {boolean} + */ + _haveParamsChanged(params = {}) { + const originalParams = [this.loadContext, this.loadDomain]; + const currentParams = [this.reloadContext, this.reloadDomain]; + if (JSON.stringify(originalParams) !== JSON.stringify(currentParams)) { + return true; + } + if ('timeRanges' in params) { + const diff = JSON.stringify(params.timeRanges) !== JSON.stringify(this.loadParams.timeRanges); + if (diff) { + return true; + } + } + if (this.useSampleModel && 'groupBy' in params) { + return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy); + } + }, + + /** + * Applies the forecast logic to the domain and context if needed before the read_group. + * After the read_group, checks the end date of the last displayed group in order to be able to + * add the next group with the ForecastColumnQuickCreate widget + * + * @private + * @override + */ + async __reload(id, params) { + if (this.handle !== id || this.isSampleModel) { + // the forecast logic should only apply when the whole view is reloading on real data + return this._super(...arguments); + } + if ("domain" in params) { + this.reloadDomain = params.domain; + } + if ("context" in params) { + this.reloadContext = params.context; + this.forecast_field = params.context.forecast_field; + this.minGroups = + params.context.fill_temporal && params.context.fill_temporal.min_groups || this.minGroups; + } + const element = this.localData[id]; + if (!this._isForecast(element, params)) { + return this._super(...arguments); + } + const fillTemporalPeriod = this.call('fillTemporalService', 'getFillTemporalPeriod', { + modelName: this.loadParams.modelName, + field: { + name: this.forecast_field, + type: this.loadParams.fields[this.forecast_field].type, + }, + granularity: this.granularity, + minGroups: this.minGroups, + }); + params.domain = fillTemporalPeriod.getDomain({ + domain: this.reloadDomain || this.loadDomain, + forceStartBound: false + }); + params.context = fillTemporalPeriod.getContext({ context: this.reloadContext || this.loadContext }); + const reload = await this._super(...arguments); + this._updateFillTemporalPeriodEnd(fillTemporalPeriod); + return reload; + }, +}); + +export { + ForecastKanbanModel, +}; diff --git a/addons/crm/static/src/js/forecast/forecast_renderers.js b/addons/crm/static/src/js/forecast/forecast_renderers.js new file mode 100644 index 00000000000..27067b0a656 --- /dev/null +++ b/addons/crm/static/src/js/forecast/forecast_renderers.js @@ -0,0 +1,31 @@ +/** @odoo-module */ +import ForecastColumnQuickCreate from './forecast_kanban_column_quick_create'; +import KanbanRenderer from 'web.KanbanRenderer'; + +const ForecastKanbanRenderer = KanbanRenderer.extend({ + /** + * Adds the widget ForecastColumnQuickCreate if there is a forecast_field and the current + * groupby targets it. It will be used to automatically add the next group for a date/datetime + * field + * + * @private + * @override + * @param {DocumentFragment} fragment + */ + _renderGrouped(fragment) { + this._super(...arguments); + let [groupby, granularity] = this.state.groupedBy[0].split(":"); + const forecast_field = this.state.context.forecast_field; + if (forecast_field && groupby === forecast_field) { + granularity = granularity || "month"; + this.forecastColumnQuickCreate = new ForecastColumnQuickCreate(this, { + addColumnLabel: granularity, + }); + this.defs.push(this.forecastColumnQuickCreate.appendTo(fragment)); + } + }, +}); + +export { + ForecastKanbanRenderer, +}; diff --git a/addons/crm/static/src/js/forecast/forecast_views.js b/addons/crm/static/src/js/forecast/forecast_views.js new file mode 100644 index 00000000000..6b19eeec85e --- /dev/null +++ b/addons/crm/static/src/js/forecast/forecast_views.js @@ -0,0 +1,96 @@ +/** @odoo-module */ +import { ForecastKanbanController } from './forecast_controllers'; +import { ForecastKanbanModel } from './forecast_models'; +import { ForecastKanbanRenderer } from './forecast_renderers'; +import GraphView from 'web.GraphView'; +import KanbanView from 'web.KanbanView'; +import ListView from 'web.ListView'; +import PivotView from 'web.PivotView'; +import viewRegistry from 'web.view_registry'; + +/** + * Graph view to be used for a Forecast @see ForecastModelExtension + * requires: + * - context key `forecast_field` on a date/datetime field + * - special filter "Forecast" (which must set the `forecast_filter:1` context key) + */ +const ForecastGraphView = GraphView.extend({ + /** + * @private + * @override + */ + _createSearchModel(params, extraExtensions = {}) { + Object.assign(extraExtensions, { Forecast: {} }); + return this._super(params, extraExtensions); + }, +}); +viewRegistry.add('forecast_graph', ForecastGraphView); + +/** + * Kanban view to be used for a Forecast + * @see ForecastModelExtension + * @see FillTemporalService + * @see ForecastKanbanColumnQuickCreate + * requires: + * - context key `forecast_field` on a date/datetime field + * - special filter "Forecast" (which must set the `forecast_filter:1` context key) + */ +const ForecastKanbanView = KanbanView.extend({ + config: _.extend({}, KanbanView.prototype.config, { + Renderer: ForecastKanbanRenderer, + Model: ForecastKanbanModel, + Controller: ForecastKanbanController, + }), + /** + * @private + * @override + */ + _createSearchModel(params, extraExtensions={}) { + Object.assign(extraExtensions, { Forecast: {} }); + return this._super(params, extraExtensions); + }, +}); +viewRegistry.add('forecast_kanban', ForecastKanbanView); + +/** + * List view to be used for a Forecast @see ForecastModelExtension + * requires: + * - context key `forecast_field` on a date/datetime field + * - special filter "Forecast" (which must set the `forecast_filter:1` context key) + */ +const ForecastListView = ListView.extend({ + /** + * @private + * @override + */ + _createSearchModel(params, extraExtensions = {}) { + Object.assign(extraExtensions, { Forecast: {} }); + return this._super(params, extraExtensions); + }, +}); +viewRegistry.add('forecast_list', ForecastListView); + +/** + * Pivot view to be used for a Forecast @see ForecastModelExtension + * requires: + * - context key `forecast_field` on a date/datetime field + * - special filter "Forecast" (which must set the `forecast_filter:1` context key) + */ +const ForecastPivotView = PivotView.extend({ + /** + * @private + * @override + */ + _createSearchModel(params, extraExtensions = {}) { + Object.assign(extraExtensions, { Forecast: {} }); + return this._super(params, extraExtensions); + }, +}); +viewRegistry.add('forecast_pivot', ForecastPivotView); + +export { + ForecastGraphView, + ForecastKanbanView, + ForecastListView, + ForecastPivotView, +}; diff --git a/addons/crm/static/src/xml/forecast_kanban.xml b/addons/crm/static/src/xml/forecast_kanban.xml new file mode 100644 index 00000000000..7cbbee8f0f9 --- /dev/null +++ b/addons/crm/static/src/xml/forecast_kanban.xml @@ -0,0 +1,12 @@ + + diff --git a/addons/crm/static/tests/forecast_kanban_tests.js b/addons/crm/static/tests/forecast_kanban_tests.js new file mode 100644 index 00000000000..5c4a7b25f2c --- /dev/null +++ b/addons/crm/static/tests/forecast_kanban_tests.js @@ -0,0 +1,258 @@ +/** @odoo-module */ +import FillTemporalService from '../src/js/forecast/fill_temporal_service'; +import { ForecastKanbanView } from '../src/js/forecast/forecast_views'; +import testUtils from 'web.test_utils'; + +QUnit.module('Crm Forecast Model Extension', { + beforeEach: async function () { + this.testKanbanView = { + arch: ` + + + + + +
+
+
+
`, + archs: { + "crm.lead,false,search": ` + + + + + ` + }, + data: { + 'crm.lead': { + fields: { + name: {string: 'Name', type: 'char'}, + date_deadline: {string: "Expected closing", type: 'date'}, + date_closed: {string: "Closed Date", type: 'datetime'}, + }, + records: [ + {id: 1, name: 'Lead 1', date_deadline: '2021-01-01', date_closed: '2021-01-01 00:00:00'}, + {id: 2, name: 'Lead 2', date_deadline: '2021-01-20', date_closed: '2021-01-20 00:00:00'}, + {id: 3, name: 'Lead 3', date_deadline: '2021-02-01', date_closed: '2021-02-01 00:00:00'}, + {id: 4, name: 'Lead 4', date_deadline: '2021-02-20', date_closed: '2021-02-20 00:00:00'}, + {id: 5, name: 'Lead 5', date_deadline: '2021-03-01', date_closed: '2021-03-01 00:00:00'}, + {id: 6, name: 'Lead 6', date_deadline: '2021-03-20', date_closed: '2021-03-20 00:00:00'}, + ], + }, + }, + services: { 'fillTemporalService': FillTemporalService }, + model: 'crm.lead', + View: ForecastKanbanView, + viewOptions: { + context: { + search_default_forecast: true, + search_default_groupby_date_deadline: true, + forecast_field: 'date_deadline', + }, + }, + groupBy: ['date_deadline'], + + }; + this.unPatchDate = testUtils.mock.patchDate(2021, 1, 10, 0, 0, 0); + }, + afterEach: async function () { + this.unPatchDate(); + }, + +}, function () { + QUnit.test("filter out every records before the start of the current month with forecast_filter for a date field", async function (assert) { + // the filter is used by the forecast model extension, and applies the forecast_filter context key, + // which adds a domain constraint on the field marked in the other context key forecast_field + assert.expect(7); + + const kanban = await testUtils.createView(this.testKanbanView); + + // the filter is active + assert.containsN(kanban, '.o_kanban_group', 2, "There should be 2 columns"); + assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2, + "1st column February should contain 2 record"); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2, + "2nd column March should contain 2 records"); + + // remove the filter + await testUtils.dom.click($('.o_searchview_facet:contains("Forecast") .o_facet_remove')); + + assert.containsN(kanban, '.o_kanban_group', 3, "There should be 3 columns"); + assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2, + "1st column January should contain 2 record"); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2, + "2nd column February should contain 2 records"); + assert.containsN(kanban, '.o_kanban_group:nth-child(3) .o_kanban_record', 2, + "3nd column March should contain 2 records"); + kanban.destroy(); + }); + + QUnit.test("filter out every records before the start of the current month with forecast_filter for a datetime field", async function (assert) { + // same tests as for the date field + assert.expect(7); + + this.testKanbanView.viewOptions.context = { + search_default_forecast: true, + search_default_groupby_date_closed: true, + forecast_field: 'date_closed', + }; + this.testKanbanView.groupBy = ['date_closed']; + const kanban = await testUtils.createView(this.testKanbanView); + + // with the filter + assert.containsN(kanban, '.o_kanban_group', 2, "There should be 2 columns"); + assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2, + "1st column February should contain 2 record"); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2, + "2nd column March should contain 2 records"); + + // remove the filter + await testUtils.dom.click($('.o_searchview_facet:contains("Forecast") .o_facet_remove')); + + assert.containsN(kanban, '.o_kanban_group', 3, "There should be 3 columns"); + assert.containsN(kanban, '.o_kanban_group:nth-child(1) .o_kanban_record', 2, + "1st column January should contain 2 record"); + assert.containsN(kanban, '.o_kanban_group:nth-child(2) .o_kanban_record', 2, + "2nd column February should contain 2 records"); + assert.containsN(kanban, '.o_kanban_group:nth-child(3) .o_kanban_record', 2, + "3nd column March should contain 2 records"); + kanban.destroy(); + }); +}); + +QUnit.module('Crm Fill Temporal Service', { + /** + * Remark: -> the filter with the groupBy is needed for the model_extension to access the groupby + * when created with testUtils.createView. Not needed in production. + * -> testKanbanView.groupBy is still needed to apply the groupby on the view + */ + beforeEach: async function () { + this.testKanbanView = { + arch: ` + + + + +
+
+
+
`, + archs: { + "crm.lead,false,search": ` + + + + ` + }, + data: { + 'crm.lead': { + fields: { + name: {string: 'Name', type: 'char'}, + date_deadline: {string: "Expected Closing", type: 'date'}, + }, + }, + }, + services: { 'fillTemporalService': FillTemporalService }, + model: 'crm.lead', + View: ForecastKanbanView, + viewOptions: { + context: { + search_default_forecast: true, + search_default_groupby_date_deadline: true, + forecast_field: 'date_deadline', + }, + }, + groupBy: ['date_deadline'], + }; + this.unPatchDate = testUtils.mock.patchDate(2021, 9, 10, 0, 0, 0); + }, + afterEach: async function () { + this.unPatchDate(); + }, + +}, function () { + /** + * Since mock_server does not support fill_temporal, + * we only check the domain and the context sent to the read_group, as well + * as the end value of the FillTemporal Service after the read_group (which should have been updated in the model) + */ + QUnit.test("Forecast on months, until the end of the year of the latest data", async function (assert) { + assert.expect(3); + + this.testKanbanView.data['crm.lead'].records = [ + {id: 1, name: 'Lead 1', date_deadline: '2021-01-01'}, + {id: 2, name: 'Lead 2', date_deadline: '2021-02-01'}, + {id: 3, name: 'Lead 3', date_deadline: '2021-11-01'}, + {id: 4, name: 'Lead 4', date_deadline: '2022-01-01'}, + ]; + this.testKanbanView.mockRPC = function (route, args) { + if (route === '/web/dataset/call_kw/crm.lead/web_read_group') { + assert.deepEqual(args.kwargs.context.fill_temporal, { + fill_from: "2021-10-01", + min_groups: 4, + }); + assert.deepEqual(args.kwargs.domain, [ + "&", "|", + ["date_deadline", "=", false], ["date_deadline", ">=", "2021-10-01"], + "|", + ["date_deadline", "=", false], ["date_deadline", "<", "2023-01-01"], + ]); + } + return this._super.apply(this, arguments); + }; + const kanban = await testUtils.createView(this.testKanbanView); + assert.strictEqual(kanban.call('fillTemporalService', 'getFillTemporalPeriod', { + modelName: 'crm.lead', + field: { + name: 'date_deadline', + type: 'date', + }, + granularity: 'month', + }).end.format('YYYY-MM-DD'), '2022-02-01'); + kanban.destroy(); + }); + + /** + * Since mock_server does not support fill_temporal, + * we only check the domain and the context sent to the read_group, as well + * as the end value of the FillTemporal Service after the read_group (which should have been updated in the model) + */ + QUnit.test("Forecast on years, until the end of the year of the latest data", async function (assert) { + assert.expect(3); + + this.testKanbanView.data['crm.lead'].records = [ + {id: 1, name: 'Lead 1', date_deadline: '2021-01-01'}, + {id: 2, name: 'Lead 2', date_deadline: '2022-02-01'}, + {id: 3, name: 'Lead 3', date_deadline: '2027-11-01'}, + ]; + this.testKanbanView.groupBy = ['date_deadline:year']; + this.testKanbanView.archs["crm.lead,false,search"] = + this.testKanbanView.archs["crm.lead,false,search"].replace("'date_deadline'", "'date_deadline:year'"); + this.testKanbanView.mockRPC = function (route, args) { + if (route === '/web/dataset/call_kw/crm.lead/web_read_group') { + assert.deepEqual(args.kwargs.context.fill_temporal, { + fill_from: "2021-01-01", + min_groups: 4, + }); + assert.deepEqual(args.kwargs.domain, [ + "&", "|", + ["date_deadline", "=", false], ["date_deadline", ">=", "2021-01-01"], + "|", + ["date_deadline", "=", false], ["date_deadline", "<", "2025-01-01"], + ]); + } + return this._super.apply(this, arguments); + }; + const kanban = await testUtils.createView(this.testKanbanView); + assert.strictEqual(kanban.call('fillTemporalService', 'getFillTemporalPeriod', { + modelName: 'crm.lead', + field: { + name: 'date_deadline', + type: 'date', + }, + granularity: 'year', + }).end.format('YYYY-MM-DD'), '2023-01-01'); + kanban.destroy(); + }); +});