[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
This commit is contained in:
abd-msyukyu-odoo
2021-06-30 09:51:42 +00:00
parent 56bcb418c2
commit 3df9f5d689
10 changed files with 1089 additions and 0 deletions
+5
View File
@@ -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',
],
}
@@ -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;
@@ -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,
};
@@ -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;
@@ -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;
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<template>
<t t-name="KanbanView.ForecastColumnQuickCreate">
<div class="o_column_quick_create">
<div class="o_quick_create_folded">
<span class="o_kanban_add_column"><i class="fa fa-plus" role="img" t-att-aria-label="widget.addColumnLabel" t-att-title="widget.addColumnLabel"/></span>
<span class="o_kanban_title"><t t-esc="widget.addColumnLabel"/></span>
</div>
</div>
</t>
</template>
@@ -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: `
<kanban js_class="forecast_kanban">
<field name="date_deadline"/>
<field name="date_closed"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>`,
archs: {
"crm.lead,false,search": `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
<filter name='groupby_date_closed' context="{'group_by':'date_closed'}"/>
</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: `
<kanban js_class="forecast_kanban">
<field name="date_deadline"/>
<templates>
<t t-name="kanban-box">
<div><field name="name"/></div>
</t>
</templates>
</kanban>`,
archs: {
"crm.lead,false,search": `
<search>
<filter name="forecast" string="Forecast" context="{'forecast_filter':1}"/>
<filter name='groupby_date_deadline' context="{'group_by':'date_deadline'}"/>
</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();
});
});