新增主生产计划模块

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>

View File

@@ -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');
}
}

View File

@@ -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 };

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp_mps.GroupMenu" owl="1">
<Dropdown togglerClass="'btn btn-light'">
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="dropdown-toggle o_dropdown_title">Rows</span>
</t>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_starting_inventory }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_starting_inventory')">
Starting Inventory
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_demand_forecast }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_demand_forecast')">
Demand Forecast
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_actual_demand }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_actual_demand')">
Actual Demand
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_actual_demand_year_minus_1 }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_actual_demand_year_minus_1')">
Actual Demand Y-1
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_actual_demand_year_minus_2 }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_actual_demand_year_minus_2')">
Actual Demand Y-2
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_indirect_demand }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_indirect_demand')">
Indirect Demand Forecast
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_to_replenish }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_to_replenish')">
To Replenish
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_actual_replenishment }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_actual_replenishment')">
Actual Replenishment
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_safety_stock }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_safety_stock')">
Forecasted Stock
</DropdownItem>
<DropdownItem
class="{ o_menu_item: true, selected: items.mrp_mps_show_available_to_promise }"
parentClosingMode="'none'"
onSelected="() => this._toggle_group('mrp_mps_show_available_to_promise')">
Available to Promise
</DropdownItem>
</Dropdown>
</t>
</templates>

View File

@@ -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 });

View File

@@ -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,
};

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="mrp_mps.MrpMpsControlPanel.Regular" t-inherit="web.ControlPanel.Regular" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-slot='control-panel-bottom-left']" position="replace">
<div>
<button
type="button"
class="btn btn-primary"
t-on-click.stop="_onClickReplenish"
t-on-mouseover.stop="_onMouseOverReplenish"
t-on-mouseout.stop="_onMouseOutReplenish">Replenish</button>
<button
type="button"
class='btn btn-secondary'
t-on-click.stop="_onClickCreate">Add a Product</button>
</div>
<ActionMenus t-if="isRecordSelected"
getActiveIds="() => Array.from(model.selectedRecords)"
context="{}"
domain="model.domain"
items="getActionMenuItems()"
isDomainSelected="model.isDomainSelected"
resModel="'mrp.production.schedule'"/>
</xpath>
<xpath expr="//t[@t-foreach='searchMenus']" position="before">
<GroupMenu items="groups"/>
</xpath>
</t>
<t t-name="mrp_mps.MrpMpsControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.ControlPanel.Regular']" position="replace">
<t t-call="mrp_mps.MrpMpsControlPanel.Regular"/>
</xpath>
</t>
</templates>

View File

@@ -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;
}
};