+ The master schedule translates your sales and demand forecasts into a production and purchase planning for each component.
+ It ensures everything gets scheduled on time, based on constraints such as: safety stock, production capacity, lead times.
+ It's the perfect tool to support your S&OP meetings.
+
+
+
+
+
+
diff --git a/mrp_mps/static/src/models/master_production_schedule_model.js b/mrp_mps/static/src/models/master_production_schedule_model.js
new file mode 100644
index 00000000..56682698
--- /dev/null
+++ b/mrp_mps/static/src/models/master_production_schedule_model.js
@@ -0,0 +1,317 @@
+/** @odoo-module **/
+
+import { _t } from "@web/core/l10n/translation";
+import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
+import { Mutex } from "@web/core/utils/concurrency";
+
+const { EventBus } = owl;
+
+export class MasterProductionScheduleModel extends EventBus {
+ constructor(params, services) {
+ super();
+ this.domain = [];
+ this.offset = 0;
+ this.limit = false;
+ this.params = params;
+ this.orm = services.orm;
+ this.action = services.action;
+ this.dialog = services.dialog;
+ this.selectedRecords = new Set();
+ this.mutex = new Mutex();
+ }
+
+ async load(domain, offset, limit) {
+ if (domain !== undefined) {
+ this.domain = domain;
+ }
+ if (offset !== undefined) {
+ this.offset = offset;
+ }
+ if (limit !== undefined) {
+ this.limit = limit;
+ }
+ this.data = await this.orm.call('mrp.production.schedule', 'get_mps_view_state', [this.domain, this.offset, this.limit]);
+ this.notify();
+ }
+
+ async reload(productionScheduleId) {
+ return await this.orm.call(
+ 'mrp.production.schedule',
+ 'get_impacted_schedule',
+ [productionScheduleId, this.domain],
+ ).then((productionScheduleIds) => {
+ productionScheduleIds.push(productionScheduleId);
+ return this.orm.call(
+ 'mrp.production.schedule',
+ 'get_production_schedule_view_state',
+ [productionScheduleIds],
+ );
+ }).then((production_schedule_ids) => {
+ for (var i = 0; i < production_schedule_ids.length; i++) {
+ const index = this.data.production_schedule_ids.findIndex(ps => ps.id === production_schedule_ids[i].id);
+ if (index >= 0) {
+ this.data.production_schedule_ids.splice(index, 1, production_schedule_ids[i]);
+ } else {
+ this.data.production_schedule_ids.push(production_schedule_ids[i]);
+ }
+ }
+ this.notify();
+ });
+ }
+
+ notify() {
+ this.unselectAll();
+ this.trigger('update');
+ }
+
+ /**
+ * Make an rpc to replenish the different schedules passed as arguments.
+ * If the procurementIds list is empty, it replenish all the schedules under
+ * the current domain. Reload the content after the replenish in order to
+ * display the new forecast cells to run.
+ * @private
+ * @param {Integer[]} productionScheduleIds mrp.production.schedule ids to
+ * replenish.
+ * @return {Promise}
+ */
+ _actionReplenish(productionScheduleIds, basedOnLeadTime = false) {
+ this.mutex.exec(() => {
+ return this.orm.call(
+ 'mrp.production.schedule',
+ 'action_replenish',
+ [productionScheduleIds, basedOnLeadTime],
+ ).then(() => {
+ if (productionScheduleIds.length === 1) {
+ this.reload(productionScheduleIds[0]);
+ } else {
+ this.load();
+ }
+ });
+ });
+ }
+
+ replenishAll() {
+ this.orm.search("mrp.production.schedule", this.domain).then((ids) => {
+ this._actionReplenish(ids, true);
+ });
+ }
+
+ replenishSelectedRecords() {
+ this._actionReplenish(Array.from(this.selectedRecords), false);
+ }
+
+ /**
+ * Save the forecasted quantity and reload the current schedule in order
+ * to update its To Replenish quantity and its safety stock (current and
+ * future period). Also update the other schedules linked by BoM in order
+ * to update them depending the indirect demand.
+ * @private
+ * @param {Integer} productionScheduleId mrp.production.schedule Id.
+ * @param {Integer} dateIndex period to save (column number)
+ * @param {Float} forecastQty The new forecasted quantity
+ * @return {Promise}
+ */
+ _saveForecast(productionScheduleId, dateIndex, forecastQty) {
+ return this.mutex.exec(() => {
+ this.orm.call(
+ 'mrp.production.schedule',
+ 'set_forecast_qty',
+ [productionScheduleId, dateIndex, forecastQty],
+ ).then(() => {
+ return this.reload(productionScheduleId);
+ });
+ });
+ }
+
+ /**
+ * Open the mrp.production.schedule form view in order to create the record.
+ * Once the record is created get its state and render it.
+ * @private
+ * @return {Promise}
+ */
+ _createProduct() {
+ this.mutex.exec(() => {
+ this.action.doAction('mrp_mps.action_mrp_mps_form_view', {
+ onClose: () => this.load(),
+ });
+ });
+ }
+
+ /**
+ * Open the mrp.production.schedule form view in order to edit the record.
+ * Once the record is edited get its state and render it.
+ * @private
+ * @param {Integer} productionScheduleId mrp.production.schedule Id.
+ */
+ _editProduct(productionScheduleId) {
+ this.mutex.exec(() => {
+ this.action.doAction({
+ name: 'Edit Production Schedule',
+ type: 'ir.actions.act_window',
+ res_model: 'mrp.production.schedule',
+ views: [[false, 'form']],
+ target: 'new',
+ res_id: productionScheduleId,
+ }, {
+ onClose: () => this.reload(productionScheduleId),
+ });
+ });
+ }
+
+ /**
+ * Unlink the production schedule and remove it from the DOM. Use a
+ * confirmation dialog in order to avoid a mistake from the user.
+ * @private
+ * @param {Integer[]} productionScheduleIds mrp.production.schedule Ids.
+ * @return {Promise}
+ */
+ _unlinkProduct(productionScheduleIds) {
+ function doIt() {
+ this.mutex.exec(async () => {
+ return Promise.all(productionScheduleIds.map((id) => this.orm.unlink(
+ 'mrp.production.schedule',
+ [id]
+ ))).then(() => {
+ for (const productionScheduleId of productionScheduleIds) {
+ const index = this.data.production_schedule_ids.findIndex(ps => ps.id === productionScheduleId);
+ this.data.production_schedule_ids.splice(index, 1);
+ }
+ this.notify();
+ });
+ });
+ }
+ const body = productionScheduleIds.length > 1
+ ? _t("Are you sure you want to delete these records?")
+ : _t("Are you sure you want to delete this record?");
+ this.dialog.add(ConfirmationDialog, {
+ body: body,
+ title: _t("Confirmation"),
+ confirm: doIt.bind(this),
+ });
+ }
+
+ unlinkSelectedRecord() {
+ return this._unlinkProduct(Array.from(this.selectedRecords));
+ }
+
+ /**
+ *
+ * @param {Integer} productionScheduleId mrp.production.schedule Id
+ * @param {String} action name of the action to be undertaken
+ * @param {String} dateStr name of the period
+ * @param {String} dateStart start date of the period
+ * @param {String} dateStop end date of the period
+ * @return {Promise}
+ */
+ _actionOpenDetails(productionScheduleId, action, dateStr, dateStart, dateStop) {
+ this.mutex.exec(() => {
+ return this.orm.call(
+ 'mrp.production.schedule',
+ action,
+ [productionScheduleId, dateStr, dateStart, dateStop]
+ ).then((action) => {
+ return this.action.doAction(action);
+ });
+ });
+ }
+
+ /**
+ * Save the quantity To Replenish and reload the current schedule in order
+ * to update it's safety stock and quantity in future period. Also mark
+ * the cell with a blue background in order to show that it was manually
+ * updated.
+ * @private
+ * @param {Integer} productionScheduleId mrp.production.schedule Id.
+ * @param {Integer} dateIndex period to save (column number)
+ * @param {Float} replenishQty The new quantity To Replenish
+ * @return {Promise}
+ */
+ _saveToReplenish(productionScheduleId, dateIndex, replenishQty) {
+ return this.mutex.exec(() => {
+ this.orm.call(
+ 'mrp.production.schedule',
+ 'set_replenish_qty',
+ [productionScheduleId, dateIndex, replenishQty],
+ ).then(() => {
+ return this.reload(productionScheduleId);
+ });
+ });
+ }
+
+ /**
+ * Remove the manual change of replenishQty and load the suggested value.
+ * @private
+ * @param {Integer} productionScheduleId mrp.production.schedule Id.
+ * @param {Integer} dateIndex period to save (column number)
+ * @return {Promise}
+ */
+ _removeQtyToReplenish(productionScheduleId, dateIndex) {
+ return this.mutex.exec(() => {
+ this.orm.call(
+ 'mrp.production.schedule',
+ 'remove_replenish_qty',
+ [productionScheduleId, dateIndex]
+ ).then(() => {
+ return this.reload(productionScheduleId);
+ });
+ });
+ }
+
+ _getOriginValue(productionScheduleId, dateIndex, inputName) {
+ return this.data.production_schedule_ids.find(ps => ps.id === productionScheduleId).forecast_ids[dateIndex][inputName];
+ }
+
+ /**
+ * Save the company settings and hide or display the rows.
+ * @private
+ * @param {Object} values {field_name: field_value}
+ */
+ _saveCompanySettings(values) {
+ this.mutex.exec(() => {
+ this.orm.write(
+ 'res.company',
+ [this.data.company_id],
+ values,
+ ).then(() => {
+ this.load();
+ });
+ });
+ }
+
+ mouseOverReplenish() {
+ this.trigger('mouse-over');
+ }
+
+ mouseOutReplenish() {
+ this.trigger('mouse-out');
+ }
+
+ selectAll() {
+ this.data.production_schedule_ids.map(
+ ({ id }) => this.selectedRecords.add(id)
+ );
+ }
+
+ unselectAll() {
+ this.selectedRecords.clear();
+ }
+
+ toggleRecordSelection(productionScheduleId) {
+ if (this.selectedRecords.has(productionScheduleId)) {
+ this.selectedRecords.delete(productionScheduleId);
+ } else {
+ this.selectedRecords.add(productionScheduleId);
+ }
+ this.trigger('update');
+ }
+
+ toggleSelection() {
+ if (this.selectedRecords.size === this.data.production_schedule_ids.length) {
+ this.unselectAll();
+ } else {
+ this.selectAll();
+ }
+ this.trigger('update');
+ }
+
+}
diff --git a/mrp_mps/static/src/search/group_menu.js b/mrp_mps/static/src/search/group_menu.js
new file mode 100644
index 00000000..a31d736e
--- /dev/null
+++ b/mrp_mps/static/src/search/group_menu.js
@@ -0,0 +1,22 @@
+/** @odoo-module **/
+
+import { Dropdown } from "@web/core/dropdown/dropdown";
+import { DropdownItem } from "@web/core/dropdown/dropdown_item";
+
+const { Component } = owl;
+
+export class GroupMenu extends Component {
+ get items() {
+ return this.props.items;
+ }
+
+ _toggle_group(group) {
+ const value = {};
+ value[group] = !this.props.items[group];
+ this.env.model._saveCompanySettings(value);
+ }
+
+}
+
+GroupMenu.template = "mrp_mps.GroupMenu";
+GroupMenu.components = { Dropdown, DropdownItem };
diff --git a/mrp_mps/static/src/search/group_menu.xml b/mrp_mps/static/src/search/group_menu.xml
new file mode 100644
index 00000000..89c6e293
--- /dev/null
+++ b/mrp_mps/static/src/search/group_menu.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+ Rows
+
+
+ Starting Inventory
+
+
+ Demand Forecast
+
+
+ Actual Demand
+
+
+ Actual Demand Y-1
+
+
+ Actual Demand Y-2
+
+
+ Indirect Demand Forecast
+
+
+ To Replenish
+
+
+ Actual Replenishment
+
+
+ Forecasted Stock
+
+
+ Available to Promise
+
+
+
+
+
diff --git a/mrp_mps/static/src/search/import_records.js b/mrp_mps/static/src/search/import_records.js
new file mode 100644
index 00000000..9fe10dad
--- /dev/null
+++ b/mrp_mps/static/src/search/import_records.js
@@ -0,0 +1,13 @@
+/** @odoo-module **/
+
+import { ImportRecords } from "@base_import/import_records/import_records";
+import { registry } from "@web/core/registry";
+
+const favoriteMenuRegistry = registry.category("favoriteMenu");
+const mpsImportRecordsItem = {
+ Component: ImportRecords,
+ groupNumber: 4,
+ isDisplayed: ({ config }) =>
+ config.mpsImportRecords
+};
+favoriteMenuRegistry.add("mps-import-records-menu", mpsImportRecordsItem, { sequence: 1 });
diff --git a/mrp_mps/static/src/search/mrp_mps_control_panel.js b/mrp_mps/static/src/search/mrp_mps_control_panel.js
new file mode 100644
index 00000000..c8971571
--- /dev/null
+++ b/mrp_mps/static/src/search/mrp_mps_control_panel.js
@@ -0,0 +1,139 @@
+/** @odoo-module **/
+
+import { GroupMenu } from "./group_menu";
+import { download } from "@web/core/network/download";
+import { useService } from "@web/core/utils/hooks";
+import { ActionMenus } from "@web/search/action_menus/action_menus";
+import { ControlPanel } from "@web/search/control_panel/control_panel";
+import { ExportDataDialog } from "@web/views/view_dialogs/export_data_dialog";
+
+
+export class MrpMpsControlPanel extends ControlPanel {
+ setup() {
+ super.setup();
+ this.rpc = useService("rpc");
+ this.dialogService = useService("dialog");
+ }
+
+ get model() {
+ return this.env.model;
+ }
+
+ get groups() {
+ return this.env.model.data.groups[0];
+ }
+
+ get isRecordSelected() {
+ return this.model.selectedRecords.size > 0;
+ }
+
+ getActionMenuItems() {
+ return Object.assign({}, {
+ other: [{
+ key: "export",
+ description: this.env._t("Export"),
+ callback: () => this.onExportData(),
+ }, {
+ key: "delete",
+ description: this.env._t("Delete"),
+ callback: () => this.unlinkSelectedRecord(),
+ }, {
+ key: "replenish",
+ description: this.env._t("Replenish"),
+ callback: () => this.replenishSelectedRecords(),
+ }]
+ });
+ }
+
+ /**
+ * Handles the click on replenish button. It will call action_replenish with
+ * all the Ids present in the view.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickReplenish(ev) {
+ this.env.model.replenishAll();
+ }
+
+ _onMouseOverReplenish(ev) {
+ this.model.mouseOverReplenish()
+ }
+
+ _onMouseOutReplenish(ev) {
+ this.model.mouseOutReplenish()
+ }
+
+ _onClickCreate(ev) {
+ this.env.model._createProduct();
+ }
+
+ replenishSelectedRecords() {
+ this.env.model.replenishSelectedRecords();
+ }
+
+ unlinkSelectedRecord() {
+ this.env.model.unlinkSelectedRecord();
+ }
+
+ async getExportedFields(model, import_compat, parentParams) {
+ return await this.rpc("/web/export/get_fields", {
+ ...parentParams,
+ model,
+ import_compat,
+ });
+ }
+
+ async downloadExport(fields, import_compat, format) {
+ const resIds = Array.from(this.model.selectedRecords);
+ const exportedFields = fields.map((field) => ({
+ name: field.name || field.id,
+ label: field.label || field.string,
+ store: field.store,
+ type: field.field_type,
+ }));
+ if (import_compat) {
+ exportedFields.unshift({ name: "id", label: this.env._t("External ID") });
+ }
+ await download({
+ data: {
+ data: JSON.stringify({
+ import_compat,
+ context: this.props.context,
+ domain: this.model.domain,
+ fields: exportedFields,
+ ids: resIds.length > 0 && resIds,
+ model: "mrp.production.schedule",
+ }),
+ },
+ url: `/web/export/${format}`,
+ });
+ }
+
+ /**
+ * Opens the Export Dialog
+ *
+ * @private
+ */
+ onExportData() {
+ const resIds = Array.from(this.model.selectedRecords);
+ const dialogProps = {
+ resIds,
+ context: this.props.context,
+ download: this.downloadExport.bind(this),
+ getExportedFields: this.getExportedFields.bind(this),
+ root: {
+ resModel: "mrp.production.schedule",
+ activeFields: [],
+ },
+ };
+ this.dialogService.add(ExportDataDialog, dialogProps);
+ }
+}
+
+MrpMpsControlPanel.template = "mrp_mps.MrpMpsControlPanel";
+MrpMpsControlPanel.components = {
+ ...ControlPanel.components,
+ ActionMenus,
+ GroupMenu,
+};
diff --git a/mrp_mps/static/src/search/mrp_mps_control_panel.xml b/mrp_mps/static/src/search/mrp_mps_control_panel.xml
new file mode 100644
index 00000000..f68fda77
--- /dev/null
+++ b/mrp_mps/static/src/search/mrp_mps_control_panel.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mrp_mps/static/src/search/mrp_mps_search_model.js b/mrp_mps/static/src/search/mrp_mps_search_model.js
new file mode 100644
index 00000000..28d4fe7f
--- /dev/null
+++ b/mrp_mps/static/src/search/mrp_mps_search_model.js
@@ -0,0 +1,28 @@
+/** @odoo-module **/
+
+import { Domain } from "@web/core/domain";
+import { SearchModel } from "@web/search/search_model";
+
+export class MrpMpsSearchModel extends SearchModel {
+ /**
+ * When search with field bom_id, also show components of that bom
+ *
+ * @override
+ */
+ _getFieldDomain(field, autocompleteValues) {
+ let domain = super._getFieldDomain(...arguments);
+ if (field.fieldName === "bom_id") {
+ const additionalDomain = [
+ "|",
+ ["product_id.bom_line_ids.bom_id", "ilike", autocompleteValues[0].value],
+ "|",
+ ["product_id.variant_bom_ids", "ilike", autocompleteValues[0].value],
+ "&",
+ ["product_tmpl_id.bom_ids.product_id", "=", false],
+ ["product_tmpl_id.bom_ids", "ilike", autocompleteValues[0].value],
+ ];
+ domain = Domain.or([additionalDomain, domain.toList()]);
+ }
+ return domain;
+ }
+};
diff --git a/mrp_mps/tests/__init__.py b/mrp_mps/tests/__init__.py
new file mode 100644
index 00000000..44f722c8
--- /dev/null
+++ b/mrp_mps/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- encoding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_mrp_mps
diff --git a/mrp_mps/tests/test_mrp_mps.py b/mrp_mps/tests/test_mrp_mps.py
new file mode 100644
index 00000000..5f9d433b
--- /dev/null
+++ b/mrp_mps/tests/test_mrp_mps.py
@@ -0,0 +1,517 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import date
+from odoo.tests import common, Form
+
+
+class TestMpsMps(common.TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ """ Define a multi level BoM and generate a production schedule with
+ default value for each of the products.
+ BoM 1:
+ Table
+ |
+ ------------------------------------
+ 1 Drawer 2 Table Legs
+ | |
+ ---------------- -------------------
+ 4 Screw 2 Table Legs 4 Screw 4 Bolt
+ |
+ -------------------
+ 4 Screw 4 Bolt
+
+ BoM 2 and 3:
+ Wardrobe Chair
+ | |
+ 3 Drawer 4 Table Legs
+ """
+ super().setUpClass()
+
+ cls.table = cls.env['product.product'].create({
+ 'name': 'Table',
+ 'type': 'product',
+ })
+ cls.drawer = cls.env['product.product'].create({
+ 'name': 'Drawer',
+ 'type': 'product',
+ })
+ cls.table_leg = cls.env['product.product'].create({
+ 'name': 'Table Leg',
+ 'type': 'product',
+ })
+ cls.screw = cls.env['product.product'].create({
+ 'name': 'Screw',
+ 'type': 'product',
+ })
+ cls.bolt = cls.env['product.product'].create({
+ 'name': 'Bolt',
+ 'type': 'product',
+ })
+ bom_form_table = Form(cls.env['mrp.bom'])
+ bom_form_table.product_tmpl_id = cls.table.product_tmpl_id
+ bom_form_table.product_qty = 1
+ cls.bom_table = bom_form_table.save()
+
+ with Form(cls.bom_table) as bom:
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.drawer
+ line.product_qty = 1
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.table_leg
+ line.product_qty = 2
+
+ bom_form_drawer = Form(cls.env['mrp.bom'])
+ bom_form_drawer.product_tmpl_id = cls.drawer.product_tmpl_id
+ bom_form_drawer.product_qty = 1
+ cls.bom_drawer = bom_form_drawer.save()
+
+ with Form(cls.bom_drawer) as bom:
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.table_leg
+ line.product_qty = 2
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.screw
+ line.product_qty = 4
+
+ bom_form_table_leg = Form(cls.env['mrp.bom'])
+ bom_form_table_leg.product_tmpl_id = cls.table_leg.product_tmpl_id
+ bom_form_table_leg.product_qty = 1
+ cls.bom_table_leg = bom_form_table_leg.save()
+
+ with Form(cls.bom_table_leg) as bom:
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.bolt
+ line.product_qty = 4
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.screw
+ line.product_qty = 4
+
+ cls.wardrobe = cls.env['product.product'].create({
+ 'name': 'Wardrobe',
+ 'type': 'product',
+ })
+
+ bom_form_wardrobe = Form(cls.env['mrp.bom'])
+ bom_form_wardrobe.product_tmpl_id = cls.wardrobe.product_tmpl_id
+ bom_form_wardrobe.product_qty = 1
+ cls.bom_wardrobe = bom_form_wardrobe.save()
+
+ with Form(cls.bom_wardrobe) as bom:
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.drawer
+ # because pim-odoo said '3 drawers because 4 is too much'
+ line.product_qty = 3
+
+ cls.chair = cls.env['product.product'].create({
+ 'name': 'Chair',
+ 'type': 'product',
+ })
+
+ bom_form_chair = Form(cls.env['mrp.bom'])
+ bom_form_chair.product_tmpl_id = cls.chair.product_tmpl_id
+ bom_form_chair.product_qty = 1
+ cls.bom_chair = bom_form_chair.save()
+
+ with Form(cls.bom_chair) as bom:
+ with bom.bom_line_ids.new() as line:
+ line.product_id = cls.table_leg
+ line.product_qty = 4
+
+ cls.warehouse = cls.env['stock.warehouse'].search([], limit=1)
+ cls.mps_table = cls.env['mrp.production.schedule'].create({
+ 'product_id': cls.table.id,
+ 'warehouse_id': cls.warehouse.id,
+ })
+ cls.mps_wardrobe = cls.env['mrp.production.schedule'].create({
+ 'product_id': cls.wardrobe.id,
+ 'warehouse_id': cls.warehouse.id,
+ })
+ cls.mps_chair = cls.env['mrp.production.schedule'].create({
+ 'product_id': cls.chair.id,
+ 'warehouse_id': cls.warehouse.id,
+ })
+ cls.mps_drawer = cls.env['mrp.production.schedule'].create({
+ 'product_id': cls.drawer.id,
+ 'warehouse_id': cls.warehouse.id,
+ })
+ cls.mps_table_leg = cls.env['mrp.production.schedule'].create({
+ 'product_id': cls.table_leg.id,
+ 'warehouse_id': cls.warehouse.id,
+ })
+ cls.mps_screw = cls.env['mrp.production.schedule'].create({
+ 'product_id': cls.screw.id,
+ 'warehouse_id': cls.warehouse.id,
+ })
+ cls.mps = cls.mps_table | cls.mps_wardrobe | cls.mps_chair |\
+ cls.mps_drawer | cls.mps_table_leg | cls.mps_screw
+
+ def test_basic_state(self):
+ """ Testing master product scheduling default values for client
+ action rendering.
+ """
+ mps_state = self.mps.get_mps_view_state()
+ self.assertTrue(len(mps_state['manufacturing_period']), 12)
+
+ # Remove demo data
+ production_schedule_ids = [s for s in mps_state['production_schedule_ids'] if s['id'] in self.mps.ids]
+ # Check that 6 states are returned (one by production schedule)
+ self.assertEqual(len(production_schedule_ids), 6)
+ self.assertEqual(mps_state['company_id'], self.env.user.company_id.id)
+ company_groups = mps_state['groups'][0]
+ self.assertTrue(company_groups['mrp_mps_show_starting_inventory'])
+ self.assertTrue(company_groups['mrp_mps_show_demand_forecast'])
+ self.assertTrue(company_groups['mrp_mps_show_indirect_demand'])
+ self.assertTrue(company_groups['mrp_mps_show_to_replenish'])
+ self.assertTrue(company_groups['mrp_mps_show_safety_stock'])
+
+ self.assertFalse(company_groups['mrp_mps_show_actual_demand'])
+ self.assertFalse(company_groups['mrp_mps_show_actual_replenishment'])
+ self.assertFalse(company_groups['mrp_mps_show_available_to_promise'])
+
+ # Check that quantity on forecast are empty
+ self.assertTrue(all([not forecast['starting_inventory_qty'] for forecast in production_schedule_ids[0]['forecast_ids']]))
+ self.assertTrue(all([not forecast['forecast_qty'] for forecast in production_schedule_ids[0]['forecast_ids']]))
+ self.assertTrue(all([not forecast['replenish_qty'] for forecast in production_schedule_ids[0]['forecast_ids']]))
+ self.assertTrue(all([not forecast['safety_stock_qty'] for forecast in production_schedule_ids[0]['forecast_ids']]))
+ self.assertTrue(all([not forecast['indirect_demand_qty'] for forecast in production_schedule_ids[0]['forecast_ids']]))
+ # Check that there is 12 periods for each forecast
+ self.assertTrue(all([len(production_schedule_id['forecast_ids']) == 12 for production_schedule_id in production_schedule_ids]))
+
+ def test_forecast_1(self):
+ """ Testing master product scheduling """
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_screw.id,
+ 'date': date.today(),
+ 'forecast_qty': 100
+ })
+ screw_mps_state = self.mps_screw.get_production_schedule_view_state()[0]
+ forecast_at_first_period = screw_mps_state['forecast_ids'][0]
+ self.assertEqual(forecast_at_first_period['forecast_qty'], 100)
+ self.assertEqual(forecast_at_first_period['replenish_qty'], 100)
+ self.assertEqual(forecast_at_first_period['safety_stock_qty'], 0)
+
+ self.env['stock.quant']._update_available_quantity(self.mps_screw.product_id, self.warehouse.lot_stock_id, 50)
+ # Invalidate qty_available on product.product
+ self.env.invalidate_all()
+ screw_mps_state = self.mps_screw.get_production_schedule_view_state()[0]
+ forecast_at_first_period = screw_mps_state['forecast_ids'][0]
+ self.assertEqual(forecast_at_first_period['forecast_qty'], 100)
+ self.assertEqual(forecast_at_first_period['replenish_qty'], 50)
+ self.assertEqual(forecast_at_first_period['safety_stock_qty'], 0)
+
+ self.mps_screw.max_to_replenish_qty = 20
+ screw_mps_state = self.mps_screw.get_production_schedule_view_state()[0]
+ forecast_at_first_period = screw_mps_state['forecast_ids'][0]
+ self.assertEqual(forecast_at_first_period['forecast_qty'], 100)
+ self.assertEqual(forecast_at_first_period['replenish_qty'], 20)
+ self.assertEqual(forecast_at_first_period['safety_stock_qty'], -30)
+ forecast_at_second_period = screw_mps_state['forecast_ids'][1]
+ self.assertEqual(forecast_at_second_period['forecast_qty'], 0)
+ self.assertEqual(forecast_at_second_period['replenish_qty'], 20)
+ self.assertEqual(forecast_at_second_period['safety_stock_qty'], -10)
+ forecast_at_third_period = screw_mps_state['forecast_ids'][2]
+ self.assertEqual(forecast_at_third_period['forecast_qty'], 0)
+ self.assertEqual(forecast_at_third_period['replenish_qty'], 10)
+ self.assertEqual(forecast_at_third_period['safety_stock_qty'], 0)
+
+ def test_replenish(self):
+ """ Test to run procurement for forecasts. Check that replenish for
+ different periods will not merger purchase order line and create
+ multiple docurements. Also modify the existing quantity replenished on
+ a forecast and run the replenishment again, ensure the purchase order
+ line is updated.
+ """
+ mps_dates = self.env.company._get_date_range()
+ forecast_screw = self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_screw.id,
+ 'date': mps_dates[0][0],
+ 'forecast_qty': 100
+ })
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_screw.id,
+ 'date': mps_dates[1][0],
+ 'forecast_qty': 100
+ })
+
+ partner = self.env['res.partner'].create({
+ 'name': 'Jhon'
+ })
+ seller = self.env['product.supplierinfo'].create({
+ 'partner_id': partner.id,
+ 'price': 12.0,
+ 'delay': 0
+ })
+ self.screw.seller_ids = [(6, 0, [seller.id])]
+ self.mps_screw.action_replenish()
+ purchase_order_line = self.env['purchase.order.line'].search([('product_id', '=', self.screw.id)])
+ self.assertTrue(purchase_order_line)
+
+ # It should not create a procurement since it exists already one for the
+ # current period and the sum of lead time should be 0.
+ self.mps_screw.action_replenish(based_on_lead_time=True)
+ purchase_order_line = self.env['purchase.order.line'].search([('product_id', '=', self.screw.id)])
+ self.assertEqual(len(purchase_order_line), 1)
+
+ self.mps_screw.action_replenish()
+ purchase_order_lines = self.env['purchase.order.line'].search([('product_id', '=', self.screw.id)])
+ self.assertEqual(len(purchase_order_lines), 2)
+ self.assertEqual(len(purchase_order_lines.mapped('order_id')), 2)
+
+ # This replenish should be withtout effect since everything is already
+ # plannified.
+ self.mps_screw.action_replenish()
+ purchase_order_lines = self.env['purchase.order.line'].search([('product_id', '=', self.screw.id)])
+ self.assertEqual(len(purchase_order_lines), 2)
+
+ # Replenish an existing forecast with a procurement in progress
+ forecast_screw.forecast_qty = 150
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ self.assertEqual(screw_forecast_1['state'], 'to_relaunch')
+ self.assertTrue(screw_forecast_1['to_replenish'])
+ self.assertTrue(screw_forecast_1['forced_replenish'])
+
+ self.mps_screw.action_replenish(based_on_lead_time=True)
+ purchase_order_lines = self.env['purchase.order.line'].search([('product_id', '=', self.screw.id)])
+ self.assertEqual(len(purchase_order_lines), 2)
+ purchase_order_line = self.env['purchase.order.line'].search([('product_id', '=', self.screw.id)], order='date_planned', limit=1)
+ self.assertEqual(purchase_order_line.product_qty, 150)
+
+ forecast_screw.forecast_qty = 50
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ self.assertEqual(screw_forecast_1['state'], 'to_correct')
+ self.assertFalse(screw_forecast_1['to_replenish'])
+ self.assertFalse(screw_forecast_1['forced_replenish'])
+
+ def test_lead_times(self):
+ """ Manufacture, supplier and rules uses delay. The forecasts to
+ replenish are impacted by those delay. Ensure that the MPS state and
+ the period to replenish are correct.
+ """
+ self.env.company.manufacturing_period = 'week'
+ partner = self.env['res.partner'].create({
+ 'name': 'Jhon'
+ })
+ seller = self.env['product.supplierinfo'].create({
+ 'partner_id': partner.id,
+ 'price': 12.0,
+ 'delay': 7,
+ })
+ self.screw.seller_ids = [(6, 0, [seller.id])]
+
+ mps_dates = self.env.company._get_date_range()
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_screw.id,
+ 'date': mps_dates[0][0],
+ 'forecast_qty': 100
+ })
+
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ screw_forecast_2 = mps_screw['forecast_ids'][1]
+ screw_forecast_3 = mps_screw['forecast_ids'][2]
+
+ # Check screw forecasts state
+ self.assertEqual(screw_forecast_1['state'], 'to_launch')
+ # launched because it's in lead time frame but it do not require a
+ # replenishment. The state launched is used in order to render the cell
+ # with a grey background.
+ self.assertEqual(screw_forecast_2['state'], 'launched')
+ self.assertEqual(screw_forecast_3['state'], 'to_launch')
+ self.assertTrue(screw_forecast_1['to_replenish'])
+ self.assertFalse(screw_forecast_2['to_replenish'])
+ self.assertFalse(screw_forecast_3['to_replenish'])
+ self.assertTrue(screw_forecast_1['forced_replenish'])
+ self.assertFalse(screw_forecast_2['forced_replenish'])
+ self.assertFalse(screw_forecast_3['forced_replenish'])
+
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_screw.id,
+ 'date': mps_dates[1][0],
+ 'forecast_qty': 100
+ })
+
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ screw_forecast_2 = mps_screw['forecast_ids'][1]
+ screw_forecast_3 = mps_screw['forecast_ids'][2]
+ self.assertEqual(screw_forecast_1['state'], 'to_launch')
+ self.assertEqual(screw_forecast_2['state'], 'to_launch')
+ self.assertEqual(screw_forecast_3['state'], 'to_launch')
+ self.assertTrue(screw_forecast_1['to_replenish'])
+ self.assertTrue(screw_forecast_2['to_replenish'])
+ self.assertFalse(screw_forecast_3['to_replenish'])
+ self.assertTrue(screw_forecast_1['forced_replenish'])
+ self.assertFalse(screw_forecast_2['forced_replenish'])
+ self.assertFalse(screw_forecast_3['forced_replenish'])
+ seller.delay = 14
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ screw_forecast_2 = mps_screw['forecast_ids'][1]
+ screw_forecast_3 = mps_screw['forecast_ids'][2]
+ self.assertEqual(screw_forecast_1['state'], 'to_launch')
+ self.assertEqual(screw_forecast_2['state'], 'to_launch')
+ self.assertEqual(screw_forecast_3['state'], 'launched')
+ self.assertTrue(screw_forecast_1['to_replenish'])
+ self.assertTrue(screw_forecast_2['to_replenish'])
+ self.assertFalse(screw_forecast_3['to_replenish'])
+ self.assertTrue(screw_forecast_1['forced_replenish'])
+ self.assertFalse(screw_forecast_2['forced_replenish'])
+ self.assertFalse(screw_forecast_3['forced_replenish'])
+
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_screw.id,
+ 'date': mps_dates[2][0],
+ 'forecast_qty': 100
+ })
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ screw_forecast_2 = mps_screw['forecast_ids'][1]
+ screw_forecast_3 = mps_screw['forecast_ids'][2]
+ self.assertEqual(screw_forecast_1['state'], 'to_launch')
+ self.assertEqual(screw_forecast_2['state'], 'to_launch')
+ self.assertEqual(screw_forecast_3['state'], 'to_launch')
+ self.assertTrue(screw_forecast_1['to_replenish'])
+ self.assertTrue(screw_forecast_2['to_replenish'])
+ self.assertTrue(screw_forecast_3['to_replenish'])
+ self.assertTrue(screw_forecast_1['forced_replenish'])
+ self.assertFalse(screw_forecast_2['forced_replenish'])
+ self.assertFalse(screw_forecast_3['forced_replenish'])
+
+ self.mps_screw.action_replenish(based_on_lead_time=True)
+ purchase_order_line = self.env['purchase.order.line'].search([('product_id', '=', self.screw.id)])
+ self.assertEqual(len(purchase_order_line.mapped('order_id')), 3)
+
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ screw_forecast_2 = mps_screw['forecast_ids'][1]
+ screw_forecast_3 = mps_screw['forecast_ids'][2]
+ self.assertEqual(screw_forecast_1['state'], 'launched')
+ self.assertEqual(screw_forecast_2['state'], 'launched')
+ self.assertEqual(screw_forecast_3['state'], 'launched')
+ self.assertFalse(screw_forecast_1['to_replenish'])
+ self.assertFalse(screw_forecast_2['to_replenish'])
+ self.assertFalse(screw_forecast_3['to_replenish'])
+ self.assertFalse(screw_forecast_1['forced_replenish'])
+ self.assertFalse(screw_forecast_2['forced_replenish'])
+ self.assertFalse(screw_forecast_3['forced_replenish'])
+
+ def test_indirect_demand(self):
+ """ On a multiple BoM relation, ensure that the replenish quantity on
+ a production schedule impact the indirect demand on other production
+ that have a component as product.
+ """
+ mps_dates = self.env.company._get_date_range()
+
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_table.id,
+ 'date': mps_dates[0][0],
+ 'forecast_qty': 2
+ })
+
+ # 2 drawer from table
+ mps_drawer = self.mps_drawer.get_production_schedule_view_state()[0]
+ drawer_forecast_1 = mps_drawer['forecast_ids'][0]
+ self.assertEqual(drawer_forecast_1['indirect_demand_qty'], 2)
+ # Screw for 2 tables:
+ # 2 * 2 legs * 4 screw = 16
+ # 1 drawer = 4 + 2 * legs * 4 = 12
+ # 16 + 2 drawers = 16 + 24 = 40
+ mps_screw = self.mps_screw.get_production_schedule_view_state()[0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ self.assertEqual(screw_forecast_1['indirect_demand_qty'], 40)
+
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_wardrobe.id,
+ 'date': mps_dates[0][0],
+ 'forecast_qty': 3
+ })
+
+ # 2 drawer from table + 9 from wardrobe (3 x 3)
+ mps_drawer, mps_screw = (self.mps_drawer | self.mps_screw).get_production_schedule_view_state()
+ drawer_forecast_1 = mps_drawer['forecast_ids'][0]
+ self.assertEqual(drawer_forecast_1['indirect_demand_qty'], 11)
+ # Screw for 2 tables + 3 wardrobe:
+ # 11 drawer = 11 * 12 = 132
+ # + 2 * 2 legs * 4 = 16
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ self.assertEqual(screw_forecast_1['indirect_demand_qty'], 148)
+
+ # Ensure that a forecast on another period will not impact the forecast
+ # for current period.
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_table.id,
+ 'date': mps_dates[1][0],
+ 'forecast_qty': 2
+ })
+ mps_drawer, mps_screw = (self.mps_drawer | self.mps_screw).get_production_schedule_view_state()
+ drawer_forecast_1 = mps_drawer['forecast_ids'][0]
+ screw_forecast_1 = mps_screw['forecast_ids'][0]
+ self.assertEqual(drawer_forecast_1['indirect_demand_qty'], 11)
+ self.assertEqual(screw_forecast_1['indirect_demand_qty'], 148)
+
+ def test_impacted_schedule(self):
+ impacted_schedules = self.mps_screw.get_impacted_schedule()
+ self.assertEqual(sorted(impacted_schedules), sorted((self.mps - self.mps_screw).ids))
+
+ impacted_schedules = self.mps_drawer.get_impacted_schedule()
+ self.assertEqual(sorted(impacted_schedules), sorted((self.mps_table |
+ self.mps_wardrobe | self.mps_table_leg | self.mps_screw).ids))
+
+ def test_3_steps(self):
+ self.warehouse.manufacture_steps = 'pbm_sam'
+ self.table_leg.write({
+ 'route_ids': [(6, 0, [self.ref('mrp.route_warehouse0_manufacture')])]
+ })
+
+ self.env['mrp.product.forecast'].create({
+ 'production_schedule_id': self.mps_table_leg.id,
+ 'date': date.today(),
+ 'forecast_qty': 25
+ })
+
+ self.mps_table_leg.action_replenish()
+ mps_table_leg = self.mps_table_leg.get_production_schedule_view_state()[0]
+ self.assertEqual(mps_table_leg['forecast_ids'][0]['forecast_qty'], 25.0, "Wrong resulting value of to_supply")
+ self.assertEqual(mps_table_leg['forecast_ids'][0]['incoming_qty'], 25.0, "Wrong resulting value of incoming quantity")
+
+ def test_interwh_delay(self):
+ """
+ Suppose an interwarehouse configuration. The user adds some delays on
+ each rule of the interwh route. Then, the user defines a replenishment
+ qty on the MPS view and calls the replenishment action. This test
+ ensures that the MPS view includes the delays for the incoming quantity
+ """
+ main_warehouse = self.warehouse
+ second_warehouse = self.env['stock.warehouse'].create({
+ 'name': 'Second Warehouse',
+ 'code': 'WH02',
+ })
+ main_warehouse.write({
+ 'resupply_wh_ids': [(6, 0, second_warehouse.ids)]
+ })
+
+ interwh_route = self.env['stock.route'].search([('supplied_wh_id', '=', main_warehouse.id), ('supplier_wh_id', '=', second_warehouse.id)])
+ interwh_route.rule_ids.delay = 1
+
+ product = self.env['product.product'].create({
+ 'name': 'SuperProduct',
+ 'type': 'product',
+ 'route_ids': [(6, 0, interwh_route.ids)],
+ })
+
+ mps = self.env['mrp.production.schedule'].create({
+ 'product_id': product.id,
+ 'warehouse_id': main_warehouse.id,
+ })
+ interval_index = 3
+ mps.set_replenish_qty(interval_index, 1)
+ mps.action_replenish()
+
+ state = mps.get_production_schedule_view_state()[0]
+ for index, forecast in enumerate(state['forecast_ids']):
+ self.assertEqual(forecast['incoming_qty'], 1 if index == interval_index else 0, 'Incoming qty is incorrect for index %s' % index)
diff --git a/mrp_mps/views/mrp_bom_views.xml b/mrp_mps/views/mrp_bom_views.xml
new file mode 100644
index 00000000..b6308953
--- /dev/null
+++ b/mrp_mps/views/mrp_bom_views.xml
@@ -0,0 +1,16 @@
+
+
+
+ mrp.bom.form.inherit.mps
+ mrp.bom
+
+
+
+
+
+
+
+
diff --git a/mrp_mps/views/mrp_mps_menu_views.xml b/mrp_mps/views/mrp_mps_menu_views.xml
new file mode 100644
index 00000000..113646ce
--- /dev/null
+++ b/mrp_mps/views/mrp_mps_menu_views.xml
@@ -0,0 +1,30 @@
+
+
+
+
+ Master Production Schedule
+ mrp.production.schedule
+ mrp_mps_client_action
+
+
+
+ Add a Product
+ mrp.production.schedule
+ form
+ new
+
+
+
+
+
+
+
diff --git a/mrp_mps/views/mrp_mps_views.xml b/mrp_mps/views/mrp_mps_views.xml
new file mode 100644
index 00000000..e2b04ac4
--- /dev/null
+++ b/mrp_mps/views/mrp_mps_views.xml
@@ -0,0 +1,53 @@
+
+
+
+ mrp.production.schedule.form.view
+ mrp.production.schedule
+
+
+
+
+
+
+ mrp.production.schedule.search.view
+ mrp.production.schedule
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mrp_mps/views/mrp_product_forecast_views.xml b/mrp_mps/views/mrp_product_forecast_views.xml
new file mode 100644
index 00000000..be4f00d5
--- /dev/null
+++ b/mrp_mps/views/mrp_product_forecast_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ mrp.product.forecast.list
+ mrp.product.forecast
+
+
+
+
+
+
+
+
+
+
diff --git a/mrp_mps/views/product_product_views.xml b/mrp_mps/views/product_product_views.xml
new file mode 100644
index 00000000..29fb06d9
--- /dev/null
+++ b/mrp_mps/views/product_product_views.xml
@@ -0,0 +1,16 @@
+
+
+
+ product.product.view.form.inherit.mrp.mps
+ product.product
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mrp_mps/views/product_template_views.xml b/mrp_mps/views/product_template_views.xml
new file mode 100644
index 00000000..8ef97eb5
--- /dev/null
+++ b/mrp_mps/views/product_template_views.xml
@@ -0,0 +1,17 @@
+
+
+
+ product.template.view.form.inherit.mrp.mps
+ product.template
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mrp_mps/views/res_config_settings_views.xml b/mrp_mps/views/res_config_settings_views.xml
new file mode 100644
index 00000000..ec663957
--- /dev/null
+++ b/mrp_mps/views/res_config_settings_views.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ res.config.settings.view.form.inherit.mrp.mps
+ res.config.settings
+
+
+
+