[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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user