* 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
259 lines
12 KiB
JavaScript
259 lines
12 KiB
JavaScript
/** @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();
|
|
});
|
|
});
|