新增主生产计划模块

This commit is contained in:
qihao.gong@jikimo.com
2023-08-15 10:36:04 +08:00
parent 4a5fb0c6e4
commit 1533ef7be9
72 changed files with 25769 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
/** @odoo-module **/
import { CheckBox } from "@web/core/checkbox/checkbox";
import { useService } from "@web/core/utils/hooks";
import fieldUtils from 'web.field_utils';
const { Component, useRef, onPatched, onWillStart } = owl;
export default class MpsLineComponent extends Component {
setup() {
this.actionService = useService("action");
this.dialogService = useService("dialog");
this.orm = useService("orm");
this.field_utils = fieldUtils;
this.model = this.env.model;
this.forecastRow = useRef("forecastRow");
this.replenishRow = useRef("replenishRow");
onPatched(() => {
// after a replenishment, switch to next column if possible
const previousEl = this.replenishRow.el.getElementsByClassName('o_mrp_mps_hover')[0];
if (previousEl) {
previousEl.classList.remove('o_mrp_mps_hover');
const el = this.replenishRow.el.getElementsByClassName('o_mrp_mps_forced_replenish')[0];
if (el) {
el.classList.add('o_mrp_mps_hover');
}
}
});
onWillStart(async () => {
this.model.on('mouse-over', this, () => this._onMouseOverReplenish());
this.model.on('mouse-out', this, () => this._onMouseOutReplenish());
});
}
get productionSchedule() {
return this.props.data;
}
get groups() {
return this.props.groups;
}
get formatFloat() {
return this.field_utils.format.float;
}
get isSelected() {
return this.model.selectedRecords.has(this.productionSchedule.id);
}
/**
* Handles the click on replenish button. It will call action_replenish with
* all the Ids present in the view.
* @private
* @param {Integer} id mrp.production.schedule Id.
*/
_onClickReplenish(id) {
this.model._actionReplenish([id]);
}
/**
* Handles the click on product name. It will open the product form view
* @private
* @param {MouseEvent} ev
*/
_onClickRecordLink(ev) {
this.actionService.doAction({
type: 'ir.actions.act_window',
res_model: ev.currentTarget.dataset.model,
res_id: Number(ev.currentTarget.dataset.resId),
views: [[false, 'form']],
target: 'current',
});
}
/**
* Handles the click on `min..max` or 'targeted stock' Event. It will open
* a form view in order to edit a production schedule and update the
* template on save.
*
* @private
* @param {MouseEvent} ev
* @param {Integer} id mrp.production.schedule Id.
*/
_onClickEdit(ev, id) {
this.model._editProduct(id);
}
/**
* Handles the click on unlink button. A dialog ask for a confirmation and
* it will unlink the product.
* @private
* @param {Object} id mrp.production.schedule Id.
*/
_onClickUnlink(id) {
this.model._unlinkProduct([id]);
}
_onClickOpenDetails(ev) {
const dateStart = ev.target.dataset.date_start;
const dateStop = ev.target.dataset.date_stop;
const dateStr = this.model.data.dates[ev.target.dataset.date_index];
const action = ev.target.dataset.action;
const productionScheduleId = Number(ev.target.closest('.o_mps_content').dataset.id);
this.model._actionOpenDetails(productionScheduleId, action, dateStr, dateStart, dateStop);
}
/**
* Handles the change on a forecast cell.
* @private
* @param {Event} ev
* @param {Object} productionScheduleId mrp.production.schedule Id.
*/
_onChangeForecast(ev, productionScheduleId) {
const dateIndex = parseInt(ev.target.dataset.date_index);
const forecastQty = ev.target.value;
if (forecastQty === "" || isNaN(forecastQty)) {
ev.target.value = this.model._getOriginValue(productionScheduleId, dateIndex, 'forecast_qty');
} else {
this.model._saveForecast(productionScheduleId, dateIndex, forecastQty).then(() => {
const inputSelector = 'input[data-date_index="' + (dateIndex + 1) + '"]';
const nextInput = this.forecastRow.el.querySelector(inputSelector);
if (nextInput) {
nextInput.select();
}
}, () => {
ev.target.value = this.model._getOriginValue(productionScheduleId, dateIndex, 'forecast_qty');
});
}
}
/**
* Handles the quantity To Replenish change on a forecast cell.
* @private
* @param {Event} ev
* @param {Object} productionScheduleId mrp.production.schedule Id.
*/
_onChangeToReplenish(ev, productionScheduleId) {
const dateIndex = parseInt(ev.target.dataset.date_index);
const replenishQty = ev.target.value;
if (replenishQty === "" || isNaN(replenishQty)) {
ev.target.value = this.model._getOriginValue(productionScheduleId, dateIndex, 'replenish_qty');
} else {
this.model._saveToReplenish(productionScheduleId, dateIndex, replenishQty).then(() => {
const inputSelector = 'input[data-date_index="' + (dateIndex + 1) + '"]';
const nextInput = this.replenishRow.el.querySelector(inputSelector);
if (nextInput) {
nextInput.select();
}
}, () => {
ev.target.value = this.model._getOriginValue(productionScheduleId, dateIndex, 'replenish_qty');
});
}
}
async _onClickForecastReport() {
const action = await this.orm.call(
"product.product",
"action_product_forecast_report",
[[this.productionSchedule.id]],
);
action.context = {
active_model: "product.product",
active_id: this.productionSchedule.product_id[0],
warehouse_id: this.productionSchedule.warehouse_id && this.productionSchedule.warehouse_id[0],
};
return this.actionService.doAction(action);
}
_onClickAutomaticMode(ev, productionScheduleId) {
const dateIndex = parseInt(ev.target.dataset.date_index);
this.model._removeQtyToReplenish(productionScheduleId, dateIndex);
}
_onFocusInput(ev) {
ev.target.select();
}
_onMouseOverReplenish(ev) {
const el = this.replenishRow.el.getElementsByClassName('o_mrp_mps_forced_replenish')[0];
if (el) {
el.classList.add('o_mrp_mps_hover');
}
}
_onMouseOutReplenish(ev) {
const el = this.replenishRow.el.getElementsByClassName('o_mrp_mps_hover')[0];
if (el) {
el.classList.remove('o_mrp_mps_hover');
}
}
toggleSelection(ev, productionScheduleId) {
this.model.toggleRecordSelection(productionScheduleId);
}
}
MpsLineComponent.template = 'mrp_mps.MpsLineComponent';
MpsLineComponent.components = {
CheckBox,
};

View File

@@ -0,0 +1,76 @@
.o_mrp_mps {
button {
&.o_mrp_mps_procurement {
background-color: $gray-200;
border: none;
}
&.o_no_padding {
padding: 0 0 0 0;
}
}
input:not(.form-check-input) {
border-style: groove;
height: 100%;
padding-top: 0;
}
table {
thead th {
@include o-position-sticky($top: 0px, $left: 0px);
z-index: 10;
border-top: none;
&:first-child {
z-index: 11;
height: 1em;
}
}
tbody th {
@include o-position-sticky($left: 0px);
&:first-child,&:nth-child(2) {
z-index: 1;
background-color: $gray-200;
}
}
th {
vertical-align: middle;
}
.o_mps_inline {
display: inline-flex;
max-width: 38%;
}
}
tr {
line-height: 1em;
min-height: 1em;
height: 1em;
}
.btn {
line-height: 1em;
}
.form-control {
padding: 0em 0em;
color: $black;
}
.o_input {
line-height: 1em;
}
.o_mrp_mps_hover {
background-color: #d6d8d9;
&.alert-success {
background-color: darken(#dff0d8, 10%);
}
&.alert-warning {
background-color: darken(#fcf8e3, 10%);
}
}
}

View File

@@ -0,0 +1,184 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp_mps.MpsLineComponent" owl="1">
<tbody class="o_mps_content" t-att-data-id="productionSchedule.id" t-att-data-warehouse_id="'warehouse_id' in productionSchedule and productionSchedule.warehouse_id[0]">
<tr class="bg-light">
<th>
<CheckBox value='isSelected' onChange.bind="(ev) => this.toggleSelection(ev, productionSchedule.id)"/>
</th>
<th scope="col">
<a href="#" class="o_mrp_mps_record_url" t-att-data-res-id="productionSchedule.product_id[0]" t-att-data-model="'product.product'" t-on-click.prevent="_onClickRecordLink"><t t-esc="productionSchedule.product_id[1]"/></a>
<span t-if="'product_uom_id' in productionSchedule" class="text-muted"> by <t t-esc="productionSchedule.product_uom_id[1]"/></span>
<span t-if="'warehouse_id' in productionSchedule"> - <t t-esc="productionSchedule.warehouse_id[1]"/></span>
<span> </span>
<a href="#" role="button" title="Forecast Report" t-attf-class="fa fa-fw fa-area-chart" t-on-click.prevent="_onClickForecastReport"/>
</th>
<th/>
<t t-foreach="productionSchedule.forecast_ids" t-as="forecast" t-key="forecast_index">
<th class="text-end pe-4">
<span t-attf-class="{{! groups.mrp_mps_show_starting_inventory and 'o_hidden' or 'text-end'}}" t-esc="formatFloat(forecast.starting_inventory_qty, false, {'digits': [false, productionSchedule.precision_digits]})" data-bs-toggle="tooltip" data-bs-placement="bottom" title="The forecasted quantity in stock at the beginning of the period."/>
</th>
</t>
</tr>
<tr name="demand_forecast_year_minus_2" t-attf-class="{{! groups.mrp_mps_show_actual_demand_year_minus_2 and 'o_hidden' or ''}}">
<th/>
<th scope="row">
<span>Actual Demand Y-2</span>
</th>
<th/>
<t t-foreach="productionSchedule.forecast_ids" t-as="forecast" t-key="forecast_index">
<th class="text-end pe-4">
<span t-esc="formatFloat(forecast.outgoing_qty_year_minus_2, false, {'digits': [false, productionSchedule.precision_digits]})"/>
</th>
</t>
</tr>
<tr name="demand_forecast_year_minus_1" t-attf-class="{{! groups.mrp_mps_show_actual_demand_year_minus_1 and 'o_hidden' or ''}}">
<th/>
<th scope="row">
<span>Actual Demand Y-1</span>
</th>
<th></th>
<t t-foreach="productionSchedule.forecast_ids" t-as="forecast" t-key="forecast_index">
<th class="text-end pe-4">
<span t-esc="formatFloat(forecast.outgoing_qty_year_minus_1, false, {'digits': [false, productionSchedule.precision_digits]})"/>
</th>
</t>
</tr>
<tr t-ref="forecastRow" name="demand_forecast" t-attf-class="{{! (groups.mrp_mps_show_demand_forecast or groups.mrp_mps_show_actual_demand) and 'o_hidden' or ''}}">
<th/>
<th scope="row">
- <span t-attf-class="{{! groups.mrp_mps_show_actual_demand and 'o_hidden' or ''}}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="The confirmed demand, based on the confirmed sales orders.">Actual</span>
<span t-attf-class="{{! (groups.mrp_mps_show_actual_demand and groups.mrp_mps_show_demand_forecast) and 'o_hidden' or ''}}"> / </span>
<span t-attf-class="{{! groups.mrp_mps_show_demand_forecast and 'o_hidden' or ''}}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="The forecasted demand. This value has to be entered manually.">Forecasted Demand</span>
</th>
<th></th>
<t t-foreach="productionSchedule.forecast_ids" t-as="forecast" t-key="forecast_index">
<th class="text-end pe-4">
<a href="#"
name="actual_demand"
t-on-click="_onClickOpenDetails"
data-action="action_open_actual_demand_details"
t-att-data-date_index="forecast_index"
t-att-data-date_start="forecast.date_start"
t-att-data-date_stop="forecast.date_stop"
t-attf-class="{{! groups.mrp_mps_show_actual_demand and 'o_hidden' or 'o_mrp_mps_open_details'}}">
<t t-esc="formatFloat(forecast.outgoing_qty, false, {'digits': [false, productionSchedule.precision_digits]})"/>
</a>
<span t-attf-class="{{! (groups.mrp_mps_show_actual_demand and groups.mrp_mps_show_demand_forecast) and 'o_hidden' or ''}}"> / </span>
<input type="text"
t-att-data-date_index="forecast_index"
t-attf-class="text-end form-control o_mrp_mps_input_forcast_qty {{! groups.mrp_mps_show_demand_forecast and 'o_hidden' or groups.mrp_mps_show_actual_demand and 'o_mps_inline' or ''}}"
t-att-value="formatFloat(forecast.forecast_qty, false, {'digits': [false, productionSchedule.precision_digits]})"
t-on-change.stop="(ev) => this._onChangeForecast(ev, productionSchedule.id)"
t-on-focus.prevent="_onFocusInput"/>
</th>
</t>
</tr>
<tr name="indirect_demand" t-attf-class="{{(! groups.mrp_mps_show_indirect_demand or ! productionSchedule.has_indirect_demand) and 'o_hidden' or ''}}">
<th/>
<th scope="row" data-bs-toggle="tooltip" data-bs-placement="bottom" title="The forecasted demand to fulfill the needs in components of the Manufacturing Orders.">
- Indirect Demand Forecast
</th>
<th/>
<t t-foreach="productionSchedule.forecast_ids" t-as="forecast" t-key="forecast_index">
<th t-attf-class="text-end pe-4 {{forecast.indirect_demand_qty == 0 and 'text-muted' or ''}}">
<t t-esc="formatFloat(forecast.indirect_demand_qty, false, {'digits': [false, productionSchedule.precision_digits]})"/>
</th>
</t>
</tr>
<tr t-ref="replenishRow" name="to_replenish" t-attf-class="{{! (groups.mrp_mps_show_to_replenish or groups.mrp_mps_show_actual_replenishment) and 'o_hidden' or ''}}">
<th/>
<th scope="row">
+ <span t-attf-class="{{! groups.mrp_mps_show_actual_replenishment and 'o_hidden' or ''}}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="The quantity being replenished, based on the Requests for Quotation and the Manufacturing Orders.">Actual</span>
<span t-attf-class="{{! (groups.mrp_mps_show_actual_replenishment and groups.mrp_mps_show_to_replenish) and 'o_hidden' or ''}}"> / </span>
<span t-attf-class="{{! groups.mrp_mps_show_to_replenish and 'o_hidden' or ''}}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="The quantity to replenish through Purchase Orders or Manufacturing Orders.">Suggested Replenishment </span>
<button type="button" title="Replenish" t-attf-class="{{! groups.mrp_mps_show_to_replenish and 'o_hidden' or 'btn btn-secondary o_no_padding o_mrp_mps_procurement'}}"
t-on-click.stop="() => this._onClickReplenish(productionSchedule.id)"
t-on-mouseover.stop="_onMouseOverReplenish"
t-on-mouseout.stop="_onMouseOutReplenish">
Replenish
</button>
</th>
<th class="text-end">
<button type="button" t-attf-class="{{! groups.mrp_mps_show_to_replenish and 'o_hidden' or 'btn btn-link o_no_padding o_mrp_mps_edit'}}" t-on-click.stop="(ev) => this._onClickEdit(ev, productionSchedule.id)">
<t t-esc="productionSchedule.min_to_replenish_qty"/> &#8804;&#8230;&#8804; <t t-esc="productionSchedule.max_to_replenish_qty"/>
</button>
</th>
<t t-foreach="productionSchedule.forecast_ids" t-as="forecast" t-key="forecast_index">
<th t-attf-class="o_forecast_stock text-end pe-4 {{
forecast.to_replenish and 'o_mrp_mps_to_replenish' or ''
}} {{
forecast.forced_replenish and 'o_mrp_mps_forced_replenish' or ''
}}">
<a href="#"
name="actual_replenishment"
t-on-click.prevent="_onClickOpenDetails"
data-action="action_open_actual_replenishment_details"
t-att-data-date_index="forecast_index"
t-att-data-date_start="forecast.date_start"
t-att-data-date_stop="forecast.date_stop"
t-attf-class="o_mrp_mps_open_details {{
! groups.mrp_mps_show_actual_replenishment and 'o_hidden'
}} {{
forecast.to_replenish and 'o_mrp_mps_to_replenish' or ''
}} {{
forecast.forced_replenish and 'o_mrp_mps_forced_replenish' or ''
}}">
<t t-esc="formatFloat(forecast.incoming_qty, false, {'digits': [false, productionSchedule.precision_digits]})"/>
</a>
<span t-attf-class="{{! (groups.mrp_mps_show_actual_replenishment and groups.mrp_mps_show_to_replenish) and 'o_hidden' or ''}}"> / </span>
<div t-attf-class="input-group {{! groups.mrp_mps_show_to_replenish and 'o_hidden' or groups.mrp_mps_show_actual_replenishment and 'o_mps_inline' or ''}}">
<button type="button"
t-if="forecast.replenish_qty_updated"
t-on-click.stop="(ev) => this._onClickAutomaticMode(ev, productionSchedule.id)"
t-att-data-date_index="forecast_index"
class="btn btn-link input-group-addon
o_mrp_mps_automatic_mode fa fa-times
o_no_padding"/>
<input type="text"
t-att-data-date_index="forecast_index"
t-attf-class="form-control text-end
o_mrp_mps_input_replenish_qty
{{forecast.state == 'launched' and 'alert-dark' or
forecast.state == 'to_relaunch' and 'alert-warning' or
forecast.state == 'to_correct' and 'alert-danger' or
forecast.to_replenish and 'alert-success' or
forecast.replenish_qty_updated and 'alert-info' or ''
}} {{
forecast.to_replenish and 'o_mrp_mps_to_replenish' or ''
}} {{
forecast.forced_replenish and 'o_mrp_mps_forced_replenish' or ''
}}"
t-att-value="formatFloat(forecast.replenish_qty, false, {'digits': [false, productionSchedule.precision_digits]})"
t-on-change.stop="(ev) => this._onChangeToReplenish(ev, productionSchedule.id)"
t-on-focus="_onFocusInput"/>
</div>
</th>
</t>
</tr>
<tr name="safety_stock" t-attf-class="{{! (groups.mrp_mps_show_safety_stock or groups.mrp_mps_show_available_to_promise) and 'o_hidden' or ''}}">
<th/>
<th scope="row">
= <span t-attf-class="{{! groups.mrp_mps_show_available_to_promise and 'o_hidden' or ''}}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Quantity predicted to be available for sale at the end of the period (= to replenish - actual demand).">ATP</span>
<span t-attf-class="{{! (groups.mrp_mps_show_safety_stock and groups.mrp_mps_show_available_to_promise) and 'o_hidden' or ''}}"> / </span>
<span t-attf-class="{{! groups.mrp_mps_show_safety_stock and 'o_hidden' or ''}}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="The forecasted quantity in stock at the end of the period.">Forecasted Stock</span>
</th>
<th class="text-end">
<button type="button" t-attf-class="{{! groups.mrp_mps_show_safety_stock and 'o_hidden' or 'btn btn-link text-muted o_no_padding o_mrp_mps_edit'}}" t-on-click.stop="(ev) => this._onClickEdit(ev, productionSchedule.id)">
<span class="fa fa-bullseye text-muted fa-fw" role="img" aria-label="Forecasted" title="Forecasted"/>
<t t-esc="productionSchedule.forecast_target_qty or 0.0"/>
</button>
</th>
<t t-foreach="productionSchedule.forecast_ids" t-as="forecast" t-key="forecast_index">
<th class="text-end pe-4">
<span t-attf-class="{{! groups.mrp_mps_show_available_to_promise and 'o_hidden' or ''}}" t-esc="formatFloat(forecast.starting_inventory_qty + forecast.replenish_qty - forecast.outgoing_qty, false, {'digits': [false, productionSchedule.precision_digits]})"/>
<span t-attf-class="{{! (groups.mrp_mps_show_safety_stock and groups.mrp_mps_show_available_to_promise) and 'o_hidden' or ''}}"> / </span>
<span t-attf-class="{{forecast.safety_stock_qty &lt; 0 and 'text-danger' or ''}} {{! groups.mrp_mps_show_safety_stock and 'o_hidden' or ''}}" t-esc="formatFloat(forecast.safety_stock_qty, false, {'digits': [false, productionSchedule.precision_digits]})"/>
</th>
</t>
</tr>
</tbody>
</t>
</templates>

View File

@@ -0,0 +1,133 @@
/** @odoo-module **/
import { MrpMpsControlPanel } from '../search/mrp_mps_control_panel';
import { MrpMpsSearchModel } from '../search/mrp_mps_search_model';
import MpsLineComponent from '@mrp_mps/components/line';
import { MasterProductionScheduleModel } from '@mrp_mps/models/master_production_schedule_model';
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { getDefaultConfig } from "@web/views/view";
import { usePager } from "@web/search/pager_hook";
import { CallbackRecorder, useSetupAction } from "@web/webclient/actions/action_hook";
const { Component, onWillStart, useSubEnv, useChildSubEnv } = owl;
class MainComponent extends Component {
//--------------------------------------------------------------------------
// Lifecycle
//--------------------------------------------------------------------------
setup() {
this.action = useService("action");
this.dialog = useService("dialog");
this.orm = useService("orm");
this.viewService = useService("view");
const { orm, action, dialog } = this;
this.model = new MasterProductionScheduleModel(this.props, { orm, action, dialog });
useSubEnv({
manufacturingPeriods: [],
model: this.model,
__getContext__: new CallbackRecorder(),
__getOrderBy__: new CallbackRecorder(),
config: {
...getDefaultConfig(),
offset: 0,
limit: 20,
mpsImportRecords: true,
},
});
useSetupAction({
getContext: () => {
return this.props.action.context;
},
});
this.SearchModel = new MrpMpsSearchModel(this.env, {
user: useService("user"),
orm: this.orm,
view: useService("view"),
});
useChildSubEnv({
searchModel: this.SearchModel,
});
useBus(this.SearchModel, "update", () => {
this.env.config.offset = 0;
this.env.config.limit = 20;
this.model.load(this.SearchModel.domain, this.env.config.offset, this.env.config.limit);
});
onWillStart(async () => {
this.env.config.setDisplayName(this.env._t("Master Production Schedule"));
const views = await this.viewService.loadViews(
{
resModel: "mrp.production.schedule",
context: this.props.action.context,
views: [[false, "search"]],
}
);
await this.SearchModel.load({
resModel: "mrp.production.schedule",
context: this.props.action.context,
orderBy: "id",
searchMenuTypes: ['filter', 'favorite'],
searchViewArch: views.views.search.arch,
searchViewId: views.views.search.id,
searchViewFields: views.fields,
loadIrFilters: true
});
this.model.on('update', this, () => this.render(true));
const domain = this.props.action.domain || this.SearchModel.domain;
await this.model.load(domain, this.env.config.offset, this.env.config.limit);
});
usePager(() => {
return {
offset: this.env.config.offset,
limit: this.env.config.limit,
total: this.model.data.count,
onUpdate: async ({ offset, limit }) => {
this.env.config.offset = offset;
this.env.config.limit = limit;
this.model.load(this.SearchModel.domain, offset, limit);
this.render(true);
},
};
});
}
get lines() {
return this.model.data.production_schedule_ids;
}
get manufacturingPeriods() {
return this.model.data.dates;
}
get groups() {
return this.model.data.groups[0];
}
get isSelected() {
return this.model.selectedRecords.size === this.lines.length;
}
toggleSelection() {
this.model.toggleSelection();
}
}
MainComponent.template = 'mrp_mps.mrp_mps';
MainComponent.components = {
MrpMpsControlPanel,
MpsLineComponent,
CheckBox,
};
registry.category("actions").add("mrp_mps_client_action", MainComponent);
export default MainComponent;

View File

@@ -0,0 +1,7 @@
.o_mrp_mps {
.o_mps_period {
th {
background-color: $gray-200;
}
}
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="mrp_mps.mrp_mps" class="main o_action" owl="1">
<MrpMpsControlPanel/>
<div class="o_mrp_mps o_content bg-view">
<t t-if="lines.length">
<div class="text-nowrap mr0 ml0">
<table class="table o_mps_product_table">
<thead class="table-light">
<tr class="o_mps_period">
<th>
<CheckBox value='isSelected' onChange.bind="toggleSelection"/>
</th>
<th/>
<th/>
<th class="text-end pe-4" scope="col" t-foreach="manufacturingPeriods" t-as="period" t-key="period">
<div><t t-esc="period"/></div>
</th>
</tr>
</thead>
<t t-foreach="lines" t-as="productionSchedule" t-key="productionSchedule.id">
<MpsLineComponent data="productionSchedule" groups="groups"/>
</t>
</table>
</div>
</t>
<t t-else="">
<t t-call="mrp_mps_nocontent_helper"/>
</t>
</div>
</div>
<t t-name="mrp_mps_nocontent_helper" owl="1">
<div class="o_view_nocontent">
<div class="o_nocontent_help">
<p class="o_view_nocontent_smiling_face">
No product yet. Add one to start scheduling.
</p><p>
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&amp;OP meetings.
</p>
</div>
</div>
</t>
</templates>