Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
32
frontend/stock/static/src/client_actions/multi_print.js
Normal file
32
frontend/stock/static/src/client_actions/multi_print.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
async function doMultiPrint(env, action) {
|
||||
for (const report of action.params.reports) {
|
||||
if (report.type != "ir.actions.report") {
|
||||
env.services.notification.add(_t("Incorrect type of action submitted as a report, skipping action"), {
|
||||
title: _t("Report Printing Error"),
|
||||
});
|
||||
continue
|
||||
} else if (report.report_type === "qweb-html") {
|
||||
env.services.notification.add(
|
||||
_t("HTML reports cannot be auto-printed, skipping report: %s", report.name),
|
||||
{ title: _t("Report Printing Error") }
|
||||
);
|
||||
continue
|
||||
}
|
||||
// WARNING: potential issue if pdf generation fails, then action_service defaults
|
||||
// to HTML and rest of the action chain will break w/potentially never resolving promise
|
||||
await env.services.action.doAction({ type: "ir.actions.report", ...report });
|
||||
}
|
||||
if (action.params.anotherAction) {
|
||||
return env.services.action.doAction(action.params.anotherAction);
|
||||
} else if (action.params.onClose) {
|
||||
// handle special cases such as barcode
|
||||
action.params.onClose()
|
||||
} else {
|
||||
return env.services.action.doAction("reload_context");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("do_multi_print", doMultiPrint);
|
||||
@@ -0,0 +1,157 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { download } from "@web/core/network/download";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useSetupAction } from "@web/search/action_hook";
|
||||
import { Layout } from "@web/search/layout";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
|
||||
function processLine(line) {
|
||||
return { ...line, lines: [], isFolded: true };
|
||||
}
|
||||
|
||||
function extractPrintData(lines) {
|
||||
const data = [];
|
||||
for (const line of lines) {
|
||||
const { id, model_id, model, unfoldable, level } = line;
|
||||
data.push({
|
||||
id: id,
|
||||
model_id: model_id,
|
||||
model_name: model,
|
||||
unfoldable,
|
||||
level: level || 1,
|
||||
});
|
||||
if (!line.isFolded) {
|
||||
data.push(...extractPrintData(line.lines));
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export class TraceabilityReport extends Component {
|
||||
static template = "stock.TraceabilityReport";
|
||||
static components = { Layout };
|
||||
static props = { ...standardActionServiceProps };
|
||||
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
|
||||
onWillStart(this.onWillStart);
|
||||
useSetupAction({
|
||||
getLocalState: () => ({
|
||||
lines: [...this.state.lines],
|
||||
}),
|
||||
});
|
||||
|
||||
this.state = useState({
|
||||
lines: this.props.state?.lines || [],
|
||||
});
|
||||
|
||||
const { active_id, active_model, auto_unfold, context, lot_name, ttype, url, lang } =
|
||||
this.props.action.context;
|
||||
this.controllerUrl = url;
|
||||
|
||||
this.context = context || {};
|
||||
Object.assign(this.context, {
|
||||
active_id: active_id || this.props.action.params.active_id,
|
||||
auto_unfold: auto_unfold || false,
|
||||
model: active_model || this.props.action.context.params?.active_model || false,
|
||||
lot_name: lot_name || false,
|
||||
ttype: ttype || false,
|
||||
lang: lang || false,
|
||||
});
|
||||
|
||||
if (this.context.model) {
|
||||
this.props.updateActionState({ active_model: this.context.model });
|
||||
}
|
||||
|
||||
this.display = {
|
||||
controlPanel: {},
|
||||
searchPanel: false,
|
||||
};
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
if (!this.state.lines.length) {
|
||||
const mainLines = await this.orm.call("stock.traceability.report", "get_main_lines", [
|
||||
this.context,
|
||||
]);
|
||||
this.state.lines = mainLines.map(processLine);
|
||||
}
|
||||
}
|
||||
|
||||
onClickBoundLink(line) {
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: line.res_model,
|
||||
res_id: line.res_id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
onClickPartner(line) {
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "res.partner",
|
||||
res_id: line.partner_id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
onClickOpenLot(line) {
|
||||
this.actionService.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'stock.lot',
|
||||
res_id: line.lot_id,
|
||||
views: [[false, 'form']],
|
||||
target: 'current',
|
||||
});
|
||||
}
|
||||
|
||||
onClickUpDownStream(line) {
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "stock_report_generic",
|
||||
name: _t("Traceability Report"),
|
||||
context: {
|
||||
active_id: line.model_id,
|
||||
active_model: line.model,
|
||||
auto_unfold: true,
|
||||
lot_name: line.lot_name !== undefined && line.lot_name,
|
||||
url: "/stock/output_format/stock/active_id",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onClickPrint() {
|
||||
const data = JSON.stringify(extractPrintData(this.state.lines));
|
||||
const url = this.controllerUrl
|
||||
.replace(":active_id", this.context.active_id)
|
||||
.replace(":active_model", this.context.model)
|
||||
.replace("output_format", "pdf");
|
||||
|
||||
download({
|
||||
data: { data },
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
async toggleLine(line) {
|
||||
line.isFolded = !line.isFolded;
|
||||
if (!line.lines.length) {
|
||||
line.lines = (
|
||||
await this.orm.call("stock.traceability.report", "get_lines", [line.id], {
|
||||
model_id: line.model_id,
|
||||
model_name: line.model,
|
||||
level: line.level + 30 || 1,
|
||||
})
|
||||
).map(processLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("stock_report_generic", TraceabilityReport);
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="stock.TraceabilityReport">
|
||||
<div class="o_action">
|
||||
<Layout display="display">
|
||||
<t t-set-slot="layout-buttons">
|
||||
<div class="o_cp_buttons" role="toolbar" aria-label="Control panel buttons" t-ref="buttons">
|
||||
<button type="button" class="btn btn-primary" t-on-click="() => this.onClickPrint()">Print</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="container-fluid o_stock_reports_page o_stock_reports_no_print">
|
||||
<t t-if="state.lines.length">
|
||||
<h1 class="o_report_heading text-start">Traceability Report</h1>
|
||||
<div class="o_stock_reports_table table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="o_report_header">
|
||||
<th class="o_report_line_header">Reference</th>
|
||||
<th class="o_report_line_header">Product</th>
|
||||
<th class="o_report_line_header">Date</th>
|
||||
<th class="o_report_line_header">Lot/Serial #</th>
|
||||
<th class="o_report_line_header">From</th>
|
||||
<th class="o_report_line_header">To</th>
|
||||
<th class="o_report_line_header">Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-call="stock.ReportMRPLines">
|
||||
<t t-set="lines" t-value="state.lines"/>
|
||||
<t t-set="hasUpDown" t-value="true"/>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
<h1 t-else="" class="text-center">No operation made on this lot.</h1>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="stock.ReportMRPLines">
|
||||
<t t-foreach="lines" t-as="line" t-key="line.id">
|
||||
<t t-set="column" t-value="0" />
|
||||
<tr t-att-class="line.model === 'stock.move.line' ? 'o_stock_reports_level0' : 'o_stock_reports_default_style'">
|
||||
<t t-foreach="line.columns" t-as="col" t-key="col_index">
|
||||
<td t-att-class="line.unfoldable ? 'o_stock_reports_unfoldable' : ''">
|
||||
<t t-if="col_first">
|
||||
<span t-attf-style="margin-left: {{line.level}}px"/>
|
||||
<t t-if="hasUpDown and line.is_used ">
|
||||
<span role="img" title="Traceability Report" aria-label="Traceability Report" t-on-click.prevent="() => this.onClickUpDownStream(line)">
|
||||
<i class="fa fa-fw fa-level-up fa-rotate-270"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="line.unfoldable">
|
||||
<span class="o_stock_reports_unfoldable o_stock_reports_caret_icon" t-on-click="() => this.toggleLine(line)">
|
||||
<i class="fa fa-fw" t-att-class="line.isFolded ? 'fa-caret-right' : 'fa-caret-down'" role="img" aria-label="Unfold" title="Unfold"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<span t-if="col and line.reference === col" t-att-class="!line.unfoldable ? 'o_stock_reports_nofoldable' : ''">
|
||||
<a class="o_stock_reports_web_action" href="#" t-on-click.prevent="() => this.onClickBoundLink(line)" t-esc="col"/>
|
||||
</span>
|
||||
<span t-elif="col and ((line.picking_type_code == 'incoming' and line.location_source === col) or (line.picking_type_code == 'outgoing' and line.location_destination === col))">
|
||||
<a class="o_stock_report_partner_action" href="#" t-on-click.prevent="() => this.onClickPartner(line)" t-esc="col"/>
|
||||
</span>
|
||||
<span t-elif="col and line.lot_name === col">
|
||||
<a class="o_stock_report_lot_action" href="#" t-on-click.prevent="() => this.onClickOpenLot(line)" t-esc="col"/>
|
||||
</span>
|
||||
<t t-elif="col" t-esc="col"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
|
||||
<t t-if="!line.isFolded and line.lines.length" t-call="stock.ReportMRPLines">
|
||||
<t t-set="lines" t-value="line.lines"/>
|
||||
<t t-set="hasUpDown" t-value="false"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { formatFloat } from "@web/views/fields/formatters";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class ReceptionReportLine extends Component {
|
||||
static template = "stock.ReceptionReportLine";
|
||||
static props = {
|
||||
data: Object,
|
||||
labelReport: Object,
|
||||
parentIndex: String,
|
||||
showUom: Boolean,
|
||||
precision: Number,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.ormService = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.formatFloat = (val) => formatFloat(val, { digits: [false, this.props.precision] });
|
||||
}
|
||||
|
||||
//---- Handlers ----
|
||||
|
||||
async onClickForecast() {
|
||||
const action = await this.ormService.call(
|
||||
"stock.move",
|
||||
"action_product_forecast_report",
|
||||
[[this.data.move_out_id]],
|
||||
);
|
||||
|
||||
return this.actionService.doAction(action);
|
||||
}
|
||||
|
||||
async onClickPrint() {
|
||||
if (!this.data.move_out_id) {
|
||||
return;
|
||||
}
|
||||
const modelIds = [this.data.move_out_id];
|
||||
const productQtys = [Math.ceil(this.data.quantity) || '1'];
|
||||
|
||||
return this.actionService.doAction({
|
||||
...this.props.labelReport,
|
||||
context: { active_ids: modelIds },
|
||||
data: { docids: modelIds, quantity: productQtys.join(",") },
|
||||
});
|
||||
}
|
||||
|
||||
async onClickAssign() {
|
||||
await this.ormService.call(
|
||||
"report.stock.report_reception",
|
||||
"action_assign",
|
||||
[false, [this.data.move_out_id], [this.data.quantity], [this.data.move_ins]],
|
||||
);
|
||||
this.env.bus.trigger("update-assign-state", { isAssigned: true, tableIndex: this.props.parentIndex, lineIndex: this.data.index });
|
||||
}
|
||||
|
||||
async onClickUnassign() {
|
||||
const done = await this.ormService.call(
|
||||
"report.stock.report_reception",
|
||||
"action_unassign",
|
||||
[false, this.data.move_out_id, this.data.quantity, this.data.move_ins]
|
||||
)
|
||||
if (done) {
|
||||
this.env.bus.trigger("update-assign-state", { isAssigned: false, tableIndex: this.props.parentIndex, lineIndex: this.data.index });
|
||||
}
|
||||
}
|
||||
|
||||
//---- Getters ----
|
||||
|
||||
get data() {
|
||||
return this.props.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<tr t-name="stock.ReceptionReportLine" class="align-middle">
|
||||
<td t-esc="data.product.display_name"/>
|
||||
<td>
|
||||
<t t-esc="formatFloat(data.quantity)"/> <t t-if="props.showUom" t-esc="data.uom"/>
|
||||
<button class="btn btn-link fa fa-area-chart" t-on-click="onClickForecast" name="forecasted_report_link"/>
|
||||
</td>
|
||||
<td t-if="data.is_qty_assignable">
|
||||
<button t-if="!data.is_assigned"
|
||||
t-on-click="onClickAssign"
|
||||
class="btn btn-sm btn-primary"
|
||||
name="assign_link">
|
||||
Assign
|
||||
</button>
|
||||
<button t-else=""
|
||||
t-on-click="onClickUnassign"
|
||||
class="btn btn-sm btn-primary"
|
||||
name="unassign_link">
|
||||
Unassign
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button t-if="data.is_qty_assignable && data.source"
|
||||
class="btn btn-sm btn-primary"
|
||||
t-attf-disabled="{{ !data.is_assigned }}"
|
||||
name="print_label"
|
||||
t-on-click="onClickPrint">
|
||||
<span class="d-sm-block">Print Label</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,173 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useBus, useService } from "@web/core/utils/hooks";
|
||||
import { ControlPanel } from "@web/search/control_panel/control_panel";
|
||||
import { ReceptionReportTable } from "../reception_report_table/stock_reception_report_table";
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
|
||||
export class ReceptionReportMain extends Component {
|
||||
static template = "stock.ReceptionReportMain";
|
||||
static components = {
|
||||
ControlPanel,
|
||||
ReceptionReportTable,
|
||||
};
|
||||
static props = { ...standardActionServiceProps };
|
||||
|
||||
setup() {
|
||||
this.controlPanelDisplay = {};
|
||||
this.ormService = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.reportName = "stock.report_reception";
|
||||
this.labelReportName = "stock.report_reception_report_label";
|
||||
this.state = useState({
|
||||
sourcesToLines: {},
|
||||
});
|
||||
useBus(this.env.bus, "update-assign-state", (ev) => this._changeAssignedState(ev.detail));
|
||||
|
||||
onWillStart(async () => {
|
||||
// Check the URL if report was alreadu loaded.
|
||||
let defaultDocIds;
|
||||
const { rfield, rids } = this.props.action.context.params || {};
|
||||
if (rfield && rids) {
|
||||
const parsedIds = JSON.parse(rids);
|
||||
defaultDocIds = [rfield, parsedIds instanceof Array ? parsedIds : [parsedIds]];
|
||||
} else {
|
||||
defaultDocIds = Object.entries(this.context).find(([k,v]) => k.startsWith("default_"));
|
||||
if (!defaultDocIds) {
|
||||
// If nothing could be found, just ask for empty data.
|
||||
defaultDocIds = [false, [0]];
|
||||
}
|
||||
}
|
||||
this.contextDefaultDoc = { field: defaultDocIds[0], ids: defaultDocIds[1] };
|
||||
|
||||
if (this.contextDefaultDoc.field) {
|
||||
// Add the fields/ids to the URL, so we can properly reload them after a page refresh.
|
||||
this.props.updateActionState({ rfield: this.contextDefaultDoc.field, rids: JSON.stringify(this.contextDefaultDoc.ids) });
|
||||
}
|
||||
this.data = await this.getReportData();
|
||||
this.state.sourcesToLines = this.data.sources_to_lines;
|
||||
|
||||
const matchingReports = await this.ormService.searchRead("ir.actions.report", [
|
||||
["report_name", "in", [this.reportName, this.labelReportName]],
|
||||
]);
|
||||
this.receptionReportAction = matchingReports.find(
|
||||
(report) => report.report_name === this.reportName
|
||||
);
|
||||
this.receptionReportLabelAction = matchingReports.find(
|
||||
(report) => report.report_name === this.labelReportName
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getReportData() {
|
||||
const context = { ...this.context, [this.contextDefaultDoc.field]: this.contextDefaultDoc.ids };
|
||||
const args = [
|
||||
this.contextDefaultDoc.ids,
|
||||
{ context, report_type: "html" },
|
||||
];
|
||||
return this.ormService.call(
|
||||
"report.stock.report_reception",
|
||||
"get_report_data",
|
||||
args,
|
||||
{ context },
|
||||
);
|
||||
}
|
||||
|
||||
//---- Handlers ----
|
||||
|
||||
async onClickAssignAll() {
|
||||
const moveIds = [];
|
||||
const quantities = [];
|
||||
const inIds = [];
|
||||
|
||||
for (const lines of Object.values(this.state.sourcesToLines)) {
|
||||
for (const line of lines) {
|
||||
if (line.is_assigned) continue;
|
||||
moveIds.push(line.move_out_id);
|
||||
quantities.push(line.quantity);
|
||||
inIds.push(line.move_ins);
|
||||
}
|
||||
}
|
||||
|
||||
await this.ormService.call(
|
||||
"report.stock.report_reception",
|
||||
"action_assign",
|
||||
[false, moveIds, quantities, inIds],
|
||||
);
|
||||
this._changeAssignedState({ isAssigned: true });
|
||||
}
|
||||
|
||||
async onClickTitle(docId) {
|
||||
return this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: this.data.doc_model,
|
||||
res_id: docId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
onClickPrint() {
|
||||
return this.actionService.doAction({
|
||||
...this.receptionReportAction,
|
||||
context: { [this.contextDefaultDoc.field]: this.contextDefaultDoc.ids },
|
||||
});
|
||||
}
|
||||
|
||||
onClickPrintLabels() {
|
||||
const modelIds = [];
|
||||
const quantities = [];
|
||||
|
||||
for (const lines of Object.values(this.state.sourcesToLines)) {
|
||||
for (const line of lines) {
|
||||
if (!line.is_assigned) continue;
|
||||
modelIds.push(line.move_out_id);
|
||||
quantities.push(Math.ceil(line.quantity) || 1);
|
||||
}
|
||||
}
|
||||
if (!modelIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.actionService.doAction({
|
||||
...this.receptionReportLabelAction,
|
||||
context: { active_ids: modelIds },
|
||||
data: { docids: modelIds, quantity: quantities.join(",") },
|
||||
});
|
||||
}
|
||||
|
||||
//---- Utils ----
|
||||
|
||||
_changeAssignedState(options) {
|
||||
const { isAssigned, tableIndex, lineIndex } = options;
|
||||
|
||||
for (const [tabIndex, lines] of Object.entries(this.state.sourcesToLines)) {
|
||||
if (tableIndex && tableIndex != tabIndex) continue;
|
||||
lines.forEach(line => {
|
||||
if (isNaN(lineIndex) || lineIndex == line.index) {
|
||||
line.is_assigned = isAssigned;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//---- Getters ----
|
||||
|
||||
get context() {
|
||||
return this.props.action.context;
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.data.sources_to_lines && Object.keys(this.data.sources_to_lines).length > 0;
|
||||
}
|
||||
|
||||
get isAssignAllDisabled() {
|
||||
return Object.values(this.state.sourcesToLines).every(lines => lines.every(line => line.is_assigned || !line.is_qty_assignable));
|
||||
}
|
||||
|
||||
get isPrintLabelDisabled() {
|
||||
return Object.values(this.state.sourcesToLines).every(lines => lines.every(line => !line.is_assigned));
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("reception_report", ReceptionReportMain);
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<div t-name="stock.ReceptionReportMain" class="o_action">
|
||||
<ControlPanel display="controlPanelDisplay">
|
||||
<t t-set-slot="control-panel-always-buttons">
|
||||
<button t-on-click="onClickPrint" type="button" class="btn btn-primary" title="Print">Print</button>
|
||||
<button t-on-click="onClickAssignAll" class="btn btn-secondary" t-att-disabled="isAssignAllDisabled">Assign All</button>
|
||||
<button t-on-click="onClickPrintLabels" class="btn btn-secondary" t-att-disabled="isPrintLabelDisabled">Print Labels</button>
|
||||
</t>
|
||||
</ControlPanel>
|
||||
<div class="o_report_reception o_report_reception_no_print container-fluid justify-content-between">
|
||||
<div class="o_report_reception_header my-4">
|
||||
<h1>
|
||||
<t t-if="data.docs">
|
||||
<div t-foreach="data.docs" t-as="doc" t-key="doc.id">
|
||||
<a href="#" t-on-click.prevent="() => this.onClickTitle(doc.id)" view-type="form" t-esc="doc.name"/>
|
||||
<span t-esc="doc.display_state" t-attf-class="ms-1 align-text-top badge rounded-pill bg-opacity-50 {{ doc.state == 'done' ? 'bg-success' : 'bg-info' }}"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="data.reason"/>
|
||||
</t>
|
||||
</h1>
|
||||
</div>
|
||||
<t t-if="hasContent">
|
||||
<table class="table table-sm">
|
||||
<ReceptionReportTable
|
||||
t-foreach="state.sourcesToLines" t-as="source" t-key="source"
|
||||
index="source"
|
||||
scheduledDate="data.sources_to_formatted_scheduled_date[source]"
|
||||
lines="state.sourcesToLines[source]"
|
||||
source="data.sources_info[source]"
|
||||
labelReport="receptionReportLabelAction"
|
||||
showUom="data.show_uom"
|
||||
precision="data.precision"/>
|
||||
</table>
|
||||
</t>
|
||||
<p t-else="">
|
||||
No allocation need found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { ReceptionReportLine } from "../reception_report_line/stock_reception_report_line";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class ReceptionReportTable extends Component {
|
||||
static template = "stock.ReceptionReportTable";
|
||||
static components = {
|
||||
ReceptionReportLine,
|
||||
};
|
||||
static props = {
|
||||
index: String,
|
||||
scheduledDate: { type: String, optional: true },
|
||||
lines: Array,
|
||||
source: Array,
|
||||
labelReport: Object,
|
||||
showUom: Boolean,
|
||||
precision: Number,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.ormService = useService("orm");
|
||||
}
|
||||
|
||||
//---- Handlers ----
|
||||
|
||||
async onClickAssignAll() {
|
||||
const moveIds = [];
|
||||
const quantities = [];
|
||||
const inIds = [];
|
||||
for (const line of this.props.lines) {
|
||||
if (line.is_assigned) continue;
|
||||
moveIds.push(line.move_out_id);
|
||||
quantities.push(line.quantity);
|
||||
inIds.push(line.move_ins);
|
||||
}
|
||||
|
||||
await this.ormService.call(
|
||||
"report.stock.report_reception",
|
||||
"action_assign",
|
||||
[false, moveIds, quantities, inIds],
|
||||
);
|
||||
this.env.bus.trigger("update-assign-state", { isAssigned: true, tableIndex: this.props.index });
|
||||
}
|
||||
|
||||
async onClickLink(resModel, resId, viewType) {
|
||||
return this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: resModel,
|
||||
res_id: resId,
|
||||
views: [[false, viewType]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
async onClickPrintLabels() {
|
||||
const modelIds = [];
|
||||
const quantities = [];
|
||||
for (const line of this.props.lines) {
|
||||
if (!line.is_assigned) continue;
|
||||
modelIds.push(line.move_out_id);
|
||||
quantities.push(Math.ceil(line.quantity) || 1);
|
||||
}
|
||||
if (!modelIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.actionService.doAction({
|
||||
...this.props.labelReport,
|
||||
context: { active_ids: modelIds },
|
||||
data: { docids: modelIds, quantity: quantities.join(",") },
|
||||
});
|
||||
}
|
||||
|
||||
//---- Getters ----
|
||||
|
||||
get hasMovesIn() {
|
||||
return this.props.lines.some(line => line.move_ins && line.move_ins.length > 0);
|
||||
}
|
||||
|
||||
get hasAssignAllButton() {
|
||||
return this.props.lines.some(line => line.is_qty_assignable);
|
||||
}
|
||||
|
||||
get isAssignAllDisabled() {
|
||||
return this.props.lines.every(line => line.is_assigned);
|
||||
}
|
||||
|
||||
get isPrintLabelDisabled() {
|
||||
return this.props.lines.every(line => !line.is_assigned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="stock.ReceptionReportTable">
|
||||
<thead>
|
||||
<tr class="bg-light">
|
||||
<th>
|
||||
<i t-if="props.source[0].priority == '1'" class="o_priority o_priority_star fa fa-star"/>
|
||||
<a href="#" t-on-click.prevent="() => this.onClickLink(props.source[0].model, props.source[0].id, 'form')" t-out="props.source[0].name"/>
|
||||
<span t-if="props.source.length > 1">
|
||||
(<a href="#" t-on-click.prevent="() => this.onClickLink(props.source[1].model, props.source[1].id, 'form')" t-out="props.source[1].name"/>)
|
||||
</span>
|
||||
<span t-if="props.source[0].model == 'stock.picking' and props.source[0].partner_id">:
|
||||
<a href="#" t-on-click.prevent="() => this.onClickLink('res.partner', props.source[0].partner_id, 'form')" t-out="props.source[0].partner_name"/>
|
||||
</span>
|
||||
</th>
|
||||
<th>Expected Delivery: <t t-esc="props.scheduledDate"/></th>
|
||||
<th t-if="hasMovesIn">
|
||||
<button t-if="hasAssignAllButton" t-on-click="onClickAssignAll" class="btn btn-sm btn-primary" t-att-disabled="isAssignAllDisabled" name="assign_source_link">
|
||||
Assign All
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button t-if="hasMovesIn" t-on-click="onClickPrintLabels" class="btn btn-sm btn-primary" t-att-disabled="isPrintLabelDisabled" name="print_labels">
|
||||
<span class="d-none d-sm-block">Print Labels</span>
|
||||
<span class="d-block d-sm-none fa fa-print"/>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="props.lines" t-as="line" t-key="line.index">
|
||||
<ReceptionReportLine
|
||||
data="line"
|
||||
labelReport="props.labelReport"
|
||||
parentIndex="props.index"
|
||||
showUom="props.showUom"
|
||||
precision="props.precision"/>
|
||||
</t>
|
||||
</tbody>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
|
||||
import { DynamicRecordList } from "@web/model/relational_model/dynamic_record_list";
|
||||
import { DynamicGroupList } from "@web/model/relational_model/dynamic_group_list";
|
||||
|
||||
export class StockKanbanRenderer extends KanbanRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
}
|
||||
|
||||
// If all Inventory Overview graphs are empty, we use random sample data
|
||||
getGroupsOrRecords() {
|
||||
const { list } = this.props;
|
||||
let records = [];
|
||||
if (list instanceof DynamicRecordList) {
|
||||
records.push(...list.records);
|
||||
} else if (list instanceof DynamicGroupList) {
|
||||
list.groups.forEach(g => {
|
||||
records.push(...g.list.records);
|
||||
});
|
||||
}
|
||||
// Data type "sample" is assigned in Python to empty graph data
|
||||
let allEmpty = records.every(r => {
|
||||
return r.data.kanban_dashboard_graph.includes('"type": "sample"');
|
||||
});
|
||||
if (allEmpty) {
|
||||
records.forEach(r => {
|
||||
let parsedDashboardData = JSON.parse(r.data.kanban_dashboard_graph);
|
||||
parsedDashboardData[0].values.forEach(d => {
|
||||
d.value = Math.floor(Math.random() * 9 + 1);
|
||||
});
|
||||
r.data.kanban_dashboard_graph = JSON.stringify(parsedDashboardData);
|
||||
});
|
||||
}
|
||||
return super.getGroupsOrRecords();
|
||||
}
|
||||
}
|
||||
|
||||
export const StockKanbanView = {
|
||||
...kanbanView,
|
||||
Renderer: StockKanbanRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("stock_dashboard_kanban", StockKanbanView);
|
||||
95
frontend/stock/static/src/fields/stock_action_field.js
Normal file
95
frontend/stock/static/src/fields/stock_action_field.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { evaluateExpr } from "@web/core/py_js/py";
|
||||
import { floatField, FloatField } from "@web/views/fields/float/float_field";
|
||||
import { monetaryField, MonetaryField } from "@web/views/fields/monetary/monetary_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const fieldRegistry = registry.category("fields");
|
||||
|
||||
class StockActionField extends Component {
|
||||
static props = {
|
||||
...FloatField.props,
|
||||
...MonetaryField.props,
|
||||
actionName: { type: String, optional: false },
|
||||
actionContext: { type: String, optional: true },
|
||||
disabled: { type: String, optional: true },
|
||||
};
|
||||
static components = {
|
||||
FloatField,
|
||||
MonetaryField,
|
||||
}
|
||||
static template = "stock.actionField";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.fieldType = this.props.record.fields[this.props.name].type;
|
||||
}
|
||||
|
||||
extractProps () {
|
||||
const keysToRemove = ["actionName", "actionContext", "disabled"];
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.props).filter(([prop]) => !keysToRemove.includes(prop))
|
||||
);
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
return this.props.disabled ? evaluateExpr(this.props.disabled, this.props.record.evalContext) : false;
|
||||
}
|
||||
|
||||
_onClick(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
// Get the action name from props.options
|
||||
const actionName = this.props.actionName;
|
||||
const actionContext = evaluateExpr(this.props.actionContext, this.props.record.evalContext);
|
||||
|
||||
// const action = this.orm.call(this.props.record.resModel, actionName, this.props.record.resId);
|
||||
// Use the action service to perform the action
|
||||
this.actionService.doAction(actionName, {
|
||||
additionalContext: { ...actionContext, ...this.props.record.context },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stockActionField = {
|
||||
...floatField,
|
||||
...monetaryField,
|
||||
component: StockActionField,
|
||||
supportedOptions: [
|
||||
Object.values(
|
||||
Object.fromEntries(
|
||||
[...floatField.supportedOptions, ...monetaryField.supportedOptions].map(
|
||||
(option) => [option.name, option]
|
||||
)
|
||||
)
|
||||
),
|
||||
{
|
||||
label: _t("Action Name"),
|
||||
name: "action_name",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
extractProps: (...args) => {
|
||||
const [{ context, fieldType, options }] = args;
|
||||
const action_props = {
|
||||
actionName: options.action_name,
|
||||
disabled: options.disabled,
|
||||
actionContext: context,
|
||||
}
|
||||
let props = {...action_props}
|
||||
if (fieldType === "monetary") {
|
||||
props = { ...action_props, ...monetaryField.extractProps(...args) };
|
||||
} else if (fieldType === "float") {
|
||||
props = { ...action_props, ...floatField.extractProps(...args) };
|
||||
};
|
||||
return props;
|
||||
},
|
||||
};
|
||||
|
||||
fieldRegistry.add("stock_action_field", stockActionField);
|
||||
20
frontend/stock/static/src/fields/stock_action_field.xml
Normal file
20
frontend/stock/static/src/fields/stock_action_field.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="stock.actionField" owl="1">
|
||||
<a t-if="! disabled" href="#" t-on-click="_onClick" class="btn-link">
|
||||
<t t-call="stock.actionFieldContent"/>
|
||||
</a>
|
||||
<t t-else="">
|
||||
<t t-call="stock.actionFieldContent"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="stock.actionFieldContent" owl="1">
|
||||
<t t-if="fieldType === 'monetary'">
|
||||
<MonetaryField t-props="extractProps()"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<FloatField t-props="extractProps()"/>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,190 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
import { useSelectCreate, useOpenMany2XRecord} from "@web/views/fields/relational_utils";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Domain } from "@web/core/domain";
|
||||
|
||||
export class SMLX2ManyField extends X2ManyField {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.dirtyQuantsData = new Map();
|
||||
const selectCreate = useSelectCreate({
|
||||
resModel: "stock.quant",
|
||||
activeActions: this.activeActions,
|
||||
onSelected: (resIds) => this.selectRecord(resIds),
|
||||
onCreateEdit: () => this.createOpenRecord(),
|
||||
});
|
||||
|
||||
this.selectCreate = (params) => {
|
||||
return selectCreate(params);
|
||||
};
|
||||
this.openQuantRecord = useOpenMany2XRecord({
|
||||
resModel: "stock.quant",
|
||||
activeActions: this.activeActions,
|
||||
onRecordSaved: (record) => this.selectRecord([record.resId]),
|
||||
fieldString: this.props.string,
|
||||
is2Many: true,
|
||||
});
|
||||
}
|
||||
|
||||
get quantListViewShowOnHandOnly(){
|
||||
return true; // To override in mrp_subcontracting
|
||||
}
|
||||
|
||||
async onAdd({ context, editable } = {}) {
|
||||
if (!this.props.record.data.show_quant) {
|
||||
return super.onAdd(...arguments);
|
||||
}
|
||||
// Compute the quant offset from move lines quantity changes that were not saved yet.
|
||||
// Hence, did not yet affect quant's quantity in DB.
|
||||
await this.updateDirtyQuantsData();
|
||||
context = {
|
||||
...context,
|
||||
single_product: true,
|
||||
list_view_ref: "stock.view_stock_quant_tree_simple",
|
||||
};
|
||||
const productName = this.props.record.data.product_id.display_name;
|
||||
const title = _t("Add line: %s", productName);
|
||||
let domain = [
|
||||
["product_id", "=", this.props.record.data.product_id.id],
|
||||
["location_id", "child_of", this.props.context.default_location_id],
|
||||
["quantity", ">", 0.0],
|
||||
];
|
||||
if (this.quantListViewShowOnHandOnly) {
|
||||
domain.push(["on_hand", "=", true]);
|
||||
}
|
||||
if (this.dirtyQuantsData.size) {
|
||||
const notFullyUsed = [];
|
||||
const fullyUsed = [];
|
||||
for (const [quantId, quantData] of this.dirtyQuantsData.entries()) {
|
||||
if (quantData.available_quantity > 0) {
|
||||
notFullyUsed.push(quantId);
|
||||
} else {
|
||||
fullyUsed.push(quantId);
|
||||
}
|
||||
}
|
||||
if (fullyUsed.length) {
|
||||
domain = Domain.and([domain, [["id", "not in", fullyUsed]]]).toList();
|
||||
}
|
||||
if (notFullyUsed.length) {
|
||||
domain = Domain.or([domain, [["id", "in", notFullyUsed]]]).toList();
|
||||
}
|
||||
}
|
||||
return this.selectCreate({ domain, context, title });
|
||||
}
|
||||
|
||||
async updateDirtyQuantsData() {
|
||||
// Since changes of move line quantities will not affect the available quantity of the quant before
|
||||
// the record has been saved, it is necessary to determine the offset of the DB quant data.
|
||||
this.dirtyQuantsData.clear();
|
||||
const dirtyQuantityMoveLines = this._move_line_ids.filter(
|
||||
(ml) => !ml.data.quant_id && ml._values.quantity - ml._changes.quantity
|
||||
);
|
||||
const dirtyQuantMoveLines = this._move_line_ids.filter(
|
||||
(ml) => ml.data.quant_id.id
|
||||
);
|
||||
const dirtyMoveLines = [...dirtyQuantityMoveLines, ...dirtyQuantMoveLines];
|
||||
if (!dirtyMoveLines.length) {
|
||||
return;
|
||||
}
|
||||
const match = await this.orm.call(
|
||||
"stock.move.line",
|
||||
"get_move_line_quant_match",
|
||||
[
|
||||
this._move_line_ids
|
||||
.filter((rec) => rec.resId)
|
||||
.map((rec) => rec.resId),
|
||||
this.props.record.resId,
|
||||
dirtyMoveLines.filter((rec) => rec.resId).map((rec) => rec.resId),
|
||||
dirtyQuantMoveLines.map((ml) => ml.data.quant_id.id),
|
||||
],
|
||||
{}
|
||||
);
|
||||
const quants = match[0];
|
||||
if (!quants.length) {
|
||||
return;
|
||||
}
|
||||
const dbMoveLinesData = new Map();
|
||||
for (const data of match[1]) {
|
||||
dbMoveLinesData.set(data[0], { quantity: data[1].quantity, quantId: data[1].quant_id });
|
||||
}
|
||||
const offsetByQuant = new Map();
|
||||
for (const ml of dirtyQuantMoveLines) {
|
||||
const quantId = ml.data.quant_id.id;
|
||||
offsetByQuant.set(quantId, (offsetByQuant.get(quantId) || 0) - ml.data.quantity);
|
||||
const dbQuantId = dbMoveLinesData.get(ml.resId)?.quantId;
|
||||
if (dbQuantId && quantId != dbQuantId) {
|
||||
offsetByQuant.set(
|
||||
dbQuantId,
|
||||
(offsetByQuant.get(dbQuantId) || 0) + dbMoveLinesData.get(ml.resId).quantity
|
||||
);
|
||||
}
|
||||
}
|
||||
const offsetByQuantity = new Map();
|
||||
for (const ml of dirtyQuantityMoveLines) {
|
||||
offsetByQuantity.set(ml.resId, ml._values.quantity - ml._changes.quantity);
|
||||
}
|
||||
for (const quant of quants) {
|
||||
const quantityOffest = quant[1].move_line_ids
|
||||
.map((ml) => offsetByQuantity.get(ml) || 0)
|
||||
.reduce((val, sum) => val + sum, 0);
|
||||
const quantOffest = offsetByQuant.get(quant[0]) || 0;
|
||||
this.dirtyQuantsData.set(quant[0], {
|
||||
available_quantity: quant[1].available_quantity + quantityOffest + quantOffest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async selectRecord(res_ids) {
|
||||
const demand =
|
||||
this.props.record.data.product_uom_qty -
|
||||
this._move_line_ids
|
||||
.map((ml) => ml.data.quantity)
|
||||
.reduce((val, sum) => val + sum, 0);
|
||||
const params = {
|
||||
context: { default_quant_id: res_ids[0] },
|
||||
};
|
||||
if (demand <= 0) {
|
||||
params.context.default_quantity = 0;
|
||||
} else if (this.dirtyQuantsData.has(res_ids[0])) {
|
||||
params.context.default_quantity = Math.min(
|
||||
this.dirtyQuantsData.get(res_ids[0]).available_quantity,
|
||||
demand
|
||||
);
|
||||
}
|
||||
this.list.addNewRecord(params).then((record) => {
|
||||
// Make it dirty to force the save of the record. addNewRecord make
|
||||
// the new record dirty === False by default to remove them at unfocus event
|
||||
record.dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
createOpenRecord() {
|
||||
const activeElement = document.activeElement;
|
||||
this.openQuantRecord({
|
||||
context: {
|
||||
...this.props.context,
|
||||
form_view_ref: "stock.view_stock_quant_form",
|
||||
},
|
||||
immediate: true,
|
||||
onClose: () => {
|
||||
if (activeElement) {
|
||||
activeElement.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get _move_line_ids() {
|
||||
return this.props.record.data.move_line_ids.records;
|
||||
}
|
||||
}
|
||||
|
||||
export const smlX2ManyField = {
|
||||
...x2ManyField,
|
||||
component: SMLX2ManyField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("sml_x2_many", smlX2ManyField);
|
||||
BIN
frontend/stock/static/src/img/barcode.gif
Normal file
BIN
frontend/stock/static/src/img/barcode.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,110 @@
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { getColor, getCustomColor } from "@web/core/colors/colors";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { JournalDashboardGraphField } from "@web/views/fields/journal_dashboard_graph/journal_dashboard_graph_field";
|
||||
|
||||
export class PickingTypeDashboardGraphField extends JournalDashboardGraphField {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
}
|
||||
getBarChartConfig() {
|
||||
// Only bar chart is available for picking types
|
||||
const data = [];
|
||||
const labels = [];
|
||||
const backgroundColor = [];
|
||||
|
||||
const colorPast = getColor(8, cookie.get("color_scheme"));
|
||||
const colorPresent = getColor(16, cookie.get("color_scheme"));
|
||||
const colorFuture = getColor(12, cookie.get("color_scheme"));
|
||||
this.data[0].values.forEach((pt) => {
|
||||
data.push(pt.value);
|
||||
labels.push(pt.label);
|
||||
if (pt.type === "past") {
|
||||
backgroundColor.push(colorPast);
|
||||
} else if (pt.type === "present") {
|
||||
backgroundColor.push(colorPresent);
|
||||
} else if (pt.type === "future") {
|
||||
backgroundColor.push(colorFuture);
|
||||
} else {
|
||||
backgroundColor.push(getCustomColor(cookie.get("color_scheme"), "#ebebeb", "#3C3E4B"));
|
||||
}
|
||||
});
|
||||
return {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor,
|
||||
data,
|
||||
fill: "start",
|
||||
label: this.data[0].key,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
onClick: (e) => {
|
||||
const pickingTypeId = e.chart.config._config.options.pickingTypeId;
|
||||
// If no picking type ID was provided, than this is sample data
|
||||
if (!pickingTypeId) {
|
||||
return;
|
||||
}
|
||||
const columnIndex = e.chart.tooltip.dataPoints[0].parsed.x;
|
||||
const dateCategories = {
|
||||
0: "before",
|
||||
1: "yesterday",
|
||||
2: "today",
|
||||
3: "day_1",
|
||||
4: "day_2",
|
||||
5: "after",
|
||||
};
|
||||
const dateCategory = dateCategories[columnIndex];
|
||||
const additionalContext = {
|
||||
picking_type_id: pickingTypeId,
|
||||
search_default_picking_type_id: [pickingTypeId],
|
||||
};
|
||||
// Add a filter for the given date category
|
||||
additionalContext["search_default_".concat(dateCategory)] = true;
|
||||
this.actionService.doAction("stock.click_dashboard_graph", {
|
||||
additionalContext: additionalContext
|
||||
});
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
intersect: false,
|
||||
position: "nearest",
|
||||
caretSize: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
display: false,
|
||||
},
|
||||
x: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
pickingTypeId: this.data[0].picking_type_id,
|
||||
maintainAspectRatio: false,
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.000001,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const pickingTypeDashboardGraphField = {
|
||||
component: PickingTypeDashboardGraphField,
|
||||
supportedTypes: ["text"],
|
||||
extractProps: ({ attrs }) => ({
|
||||
graphType: attrs.graph_type,
|
||||
}),
|
||||
};
|
||||
|
||||
registry.category("fields").add("picking_type_dashboard_graph", pickingTypeDashboardGraphField);
|
||||
@@ -0,0 +1,5 @@
|
||||
.o_field_picking_type_dashboard_graph .o_dashboard_graph {
|
||||
margin-bottom: 0;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
}
|
||||
3
frontend/stock/static/src/scss/forecast_widget.scss
Normal file
3
frontend/stock/static/src/scss/forecast_widget.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.o_forecast_widget_cell {
|
||||
text-align: left !important;
|
||||
}
|
||||
27
frontend/stock/static/src/scss/forecasted_details.scss
Normal file
27
frontend/stock/static/src/scss/forecasted_details.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
.o_stock_forecast {
|
||||
button[data-bs-toggle="collapse"] i::before {
|
||||
content: "\f078"; /* fa-chevron-down */
|
||||
}
|
||||
|
||||
button[data-bs-toggle="collapse"].collapsed i::before {
|
||||
content: "\f054"; /* fa-chevron-right */
|
||||
}
|
||||
|
||||
thead.o_forecasted_details_main_header tr th {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
thead.o_forecasted_details_main_header tr th:first-child {
|
||||
width:1%;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
table.o_forecasted_details_table tr td:first-child {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
table.o_forecasted_details_table tr td:nth-child(2):not(.o_forecasted_details_line_button) {
|
||||
border-left: none !important;
|
||||
padding-left: 0px !important;
|
||||
}
|
||||
}
|
||||
8
frontend/stock/static/src/scss/product_form.scss
Normal file
8
frontend/stock/static/src/scss/product_form.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.hover-show .btn-show {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.hover-show:hover .btn-show {
|
||||
opacity: 1;
|
||||
}
|
||||
58
frontend/stock/static/src/scss/report_stock_reception.scss
Normal file
58
frontend/stock/static/src/scss/report_stock_reception.scss
Normal file
@@ -0,0 +1,58 @@
|
||||
.o_report_reception {
|
||||
|
||||
.o_priority {
|
||||
&.o_priority_star {
|
||||
font-size: 1.35em;
|
||||
&.fa-star {
|
||||
color: gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
& .btn {
|
||||
&.btn-primary {
|
||||
height: 31px;
|
||||
background-color: $o-brand-primary;
|
||||
border-color: $o-brand-primary;
|
||||
&:hover:not([disabled]) {
|
||||
background-color: darken($o-brand-primary, 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
& .badge {
|
||||
line-height: .75;
|
||||
}
|
||||
|
||||
@each $-name, $-bg-color in $theme-colors {
|
||||
$-safe-text-color: color-contrast(mix($-bg-color, $o-view-background-color));
|
||||
@include bg-variant(".bg-#{$-name}-light", rgba(map-get($theme-colors, $-name), 0.5), $-safe-text-color);
|
||||
}
|
||||
& thead{
|
||||
display: table-row-group;
|
||||
}
|
||||
}
|
||||
|
||||
.o_report_reception_no_print {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.o_label_page {
|
||||
margin-left: -3mm;
|
||||
margin-right: -3mm;
|
||||
overflow: hidden;
|
||||
page-break-before: always;
|
||||
padding: 1mm 0mm 0mm;
|
||||
|
||||
&.o_label_dymo {
|
||||
font-size:80%;
|
||||
width: 57mm;
|
||||
height: 32mm;
|
||||
& span, div {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
span[itemprop="name"] {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
134
frontend/stock/static/src/scss/report_stock_rule.scss
Normal file
134
frontend/stock/static/src/scss/report_stock_rule.scss
Normal file
@@ -0,0 +1,134 @@
|
||||
.o_report_stock_rule {
|
||||
.table > :not(:first-child) {
|
||||
border-top: $border-width * 2 solid currentColor;
|
||||
}
|
||||
|
||||
.table {
|
||||
--table-border-color: #{$o-gray-300};
|
||||
}
|
||||
|
||||
.o_report_stock_rule_rule {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
}
|
||||
.o_report_stock_rule_legend {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.o_report_stock_rule_legend_line {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
width: 29%;
|
||||
margin-right: 20px;
|
||||
margin-left: 20px;
|
||||
margin-top: 15px;
|
||||
min-width: 200px;
|
||||
>.o_report_stock_rule_legend_label {
|
||||
flex: 1 1 auto;
|
||||
width: 30%;
|
||||
min-width: 100px;
|
||||
}
|
||||
>.o_report_stock_rule_legend_symbol {
|
||||
flex: 1 1 auto;
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.o_report_stock_rule_putaway {
|
||||
>p {
|
||||
text-align: center;
|
||||
color: black;
|
||||
font-weight: normal;
|
||||
font-size: 12px
|
||||
}
|
||||
}
|
||||
|
||||
.o_report_stock_rule_line {
|
||||
flex: 1 1 auto;
|
||||
height: 20px;
|
||||
>line {
|
||||
stroke: black;
|
||||
stroke-width: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.o_report_stock_rule_arrow {
|
||||
flex: 0 0 auto;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
>svg {
|
||||
>line {
|
||||
stroke: black;
|
||||
stroke-width: 1;
|
||||
}
|
||||
>polygon {
|
||||
fill: black;
|
||||
fill-opacity: 0.5;
|
||||
stroke: black;
|
||||
stroke-width: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_report_stock_rule_vertical_bar {
|
||||
flex: 0 0 auto;
|
||||
height: 20px;
|
||||
width: 2px;
|
||||
>svg {
|
||||
>line {
|
||||
stroke: black;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_report_stock_rule_rule_name {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_report_stock_rule_symbol_cell {
|
||||
border: none !important;
|
||||
>div {
|
||||
max-width: 200px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_report_stock_rule_rule_main {
|
||||
height: 100%;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.o_report_stock_rule_location_header {
|
||||
text-align: center;
|
||||
>a {
|
||||
display: block;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background-color: #efefef;
|
||||
}
|
||||
>div {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_report_stock_rule_rule_cell {
|
||||
padding:0 !important;
|
||||
>a {
|
||||
display: block;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
background-color: #efefef;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_report_stock_rule_rtl {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.o_report_stockpicking_operations table {
|
||||
thead, tbody, td, th, tr {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Before this PR, col-auto was the closest thing to flex box support.
|
||||
* However, an issue arised at the time of testing: when one of the
|
||||
* fields has long words in it, the spacing fails miserably
|
||||
* (it becomes very elongated on the vertical axis). The only way
|
||||
* to remove this effect and also with wkhtmltopdf outdated CSS support
|
||||
* is to add min-width so that it forces the fields to have a readable
|
||||
* width for this specific report. It can also be seen that this solution is
|
||||
* suggested in base styling for reports.
|
||||
*/
|
||||
.o_stock_report_header_row {
|
||||
display: -webkit-box;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
-webkit-box-pack: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
-webkit-flex-direction: row;
|
||||
-ms-flex-wrap: wrap;
|
||||
> div {
|
||||
flex: 1;
|
||||
-webkit-flex: 1;
|
||||
-webkit-box-flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
29
frontend/stock/static/src/scss/stock_empty_screen.scss
Normal file
29
frontend/stock/static/src/scss/stock_empty_screen.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
.o_view_nocontent {
|
||||
&_barcode_scanner:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: transparent url(/stock/static/img/barcode_scanner.png) no-repeat center;
|
||||
background-size: 250px 250px;
|
||||
}
|
||||
|
||||
&_replenishment:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
max-width: 500px;
|
||||
margin-bottom: 20px;
|
||||
background: transparent url(/stock/static/img/replenishment.svg) no-repeat center / contain;;
|
||||
}
|
||||
}
|
||||
|
||||
.o_nocontent_help {
|
||||
.o_view_nocontent_smiling_face.o_view_nocontent_stock:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: transparent url(/stock/static/img/empty_list.png) no-repeat center;
|
||||
background-size: 200px 200px;
|
||||
resize: both;
|
||||
}
|
||||
}
|
||||
32
frontend/stock/static/src/scss/stock_forecasted.scss
Normal file
32
frontend/stock/static/src/scss/stock_forecasted.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
.o_stock_forecasted_page {
|
||||
.o_priority {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
&.o_priority_star {
|
||||
background-color: transparent;
|
||||
font-size: 1.35em;
|
||||
&.fa-star-o {
|
||||
color: #a8a8a8;
|
||||
&:hover {
|
||||
color: gold;
|
||||
&:before{
|
||||
content: "\f005";
|
||||
}
|
||||
}
|
||||
}
|
||||
&.fa-star {
|
||||
color: gold;
|
||||
&:hover {
|
||||
color: #a8a8a8;
|
||||
&:before{
|
||||
content: "\f006";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
3
frontend/stock/static/src/scss/stock_move_list.scss
Normal file
3
frontend/stock/static/src/scss/stock_move_list.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.btn[name="action_show_details"] {
|
||||
border-width: 0;
|
||||
}
|
||||
19
frontend/stock/static/src/scss/stock_overview.scss
Normal file
19
frontend/stock/static/src/scss/stock_overview.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.o_stock_kanban .o_kanban_renderer {
|
||||
--KanbanRecord-width: 480px;
|
||||
|
||||
@include media-only(screen) {
|
||||
--KanbanGroup-width: 480px;
|
||||
}
|
||||
@include media-only(print) {
|
||||
--KanbanGroup-width: 400px;
|
||||
}
|
||||
padding: 4px;
|
||||
.o_kanban_record {
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.stock-overview-links {
|
||||
height: 5.5rem;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.o_stock_replenishment_info {
|
||||
.o_field_widget.o_small {
|
||||
display: inline;
|
||||
input {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
@mixin o-stock-reports-lines($border-width: 5px, $font-weight: inherit, $border-top-style: initial, $border-bottom-style: initial) {
|
||||
border-width: $border-width;
|
||||
border-left-style: hidden;
|
||||
border-right-style: hidden;
|
||||
font-weight: $font-weight;
|
||||
border-top-style: $border-top-style;
|
||||
border-bottom-style: $border-bottom-style;
|
||||
}
|
||||
.o_stock_reports_body_print {
|
||||
background-color: white;
|
||||
color: black;
|
||||
.o_stock_reports_level0 {
|
||||
@include o-stock-reports-lines($border-width: 1px, $font-weight: bold, $border-top-style: solid, $border-bottom-style: groove);
|
||||
}
|
||||
}
|
||||
|
||||
.o_main_content {
|
||||
.o_stock_reports_page {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
.o_stock_reports_page {
|
||||
background-color: $o-view-background-color;
|
||||
&.o_stock_reports_no_print {
|
||||
margin: $o-horizontal-padding auto;
|
||||
@include o-webclient-padding($top: $o-sheet-vpadding, $bottom: $o-sheet-vpadding);
|
||||
.o_stock_reports_level0 {
|
||||
@include o-stock-reports-lines($border-width: 1px, $font-weight: normal, $border-top-style: solid, $border-bottom-style: groove);
|
||||
}
|
||||
.o_stock_reports_table {
|
||||
thead {
|
||||
display: table-row-group;
|
||||
}
|
||||
white-space: nowrap;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.o_report_line_header {
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.o_report_header {
|
||||
border-top-style: solid;
|
||||
border-top-style: groove;
|
||||
border-bottom-style: groove;
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
.o_stock_reports_unfolded {
|
||||
display: inline-block;
|
||||
}
|
||||
.o_stock_reports_nofoldable {
|
||||
margin-left: 17px;
|
||||
}
|
||||
a.o_stock_report_lot_action {
|
||||
cursor: pointer;
|
||||
}
|
||||
a.o_stock_report_partner_action {
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_stock_reports_unfolded td + td {
|
||||
visibility: hidden;
|
||||
}
|
||||
div.o_stock_reports_web_action,
|
||||
span.o_stock_reports_web_action, i.fa,
|
||||
span.o_stock_reports_unfoldable, span.o_stock_reports_foldable, a.o_stock_reports_web_action {
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_stock_reports_caret_icon {
|
||||
margin-left: -3px;
|
||||
}
|
||||
th {
|
||||
border-bottom: thin groove;
|
||||
}
|
||||
.o_stock_reports_level1 {
|
||||
@include o-stock-reports-lines($border-width: 2px, $border-top-style: hidden, $border-bottom-style: solid);
|
||||
}
|
||||
.o_stock_reports_level2 {
|
||||
@include o-stock-reports-lines($border-width: 1px, $border-top-style: solid, $border-bottom-style: solid);
|
||||
> td > span:last-child {
|
||||
margin-left: 25px;
|
||||
}
|
||||
}
|
||||
.o_stock_reports_default_style {
|
||||
@include o-stock-reports-lines($border-width: 0px, $border-top-style: solid, $border-bottom-style: solid);
|
||||
> td > span:last-child {
|
||||
margin-left: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, markup } from "@odoo/owl";
|
||||
|
||||
export class ForecastedButtons extends Component {
|
||||
static template = "stock.ForecastedButtons";
|
||||
static props = {
|
||||
action: Object,
|
||||
resModel: { type: String, optional: true },
|
||||
reloadReport: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.context = this.props.action.context;
|
||||
this.productId = this.context.active_id;
|
||||
this.resModel = this.props.resModel || this.context.active_model || this.context.params?.active_model || 'product.template';
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an action open a wizard. If the wizard is discarded, this
|
||||
* method does nothing, otherwise it reloads the report.
|
||||
* @param {Object | undefined} res
|
||||
*/
|
||||
_onClose(res) {
|
||||
return res?.special || !res?.noReload || this.props.reloadReport();
|
||||
}
|
||||
|
||||
async _onClickReplenish() {
|
||||
const context = { ...this.context };
|
||||
if (this.resModel === 'product.product') {
|
||||
context.default_product_id = this.productId;
|
||||
} else if (this.resModel === 'product.template') {
|
||||
context.default_product_tmpl_id = this.productId;
|
||||
}
|
||||
context.default_warehouse_id = this.context.warehouse_id;
|
||||
|
||||
const action = {
|
||||
res_model: 'product.replenish',
|
||||
name: _t('Product Replenish'),
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'form']],
|
||||
target: 'new',
|
||||
context: context,
|
||||
};
|
||||
return this.actionService.doAction(action, { onClose: this._onClose.bind(this) });
|
||||
}
|
||||
|
||||
async _onClickUpdateQuantity() {
|
||||
const action = await this.orm.call(this.resModel, "action_open_quants", [[this.productId]]);
|
||||
if (action.res_model === "stock.quant") { // Quant view in inventory mode.
|
||||
action.views = [[false, "list"]];
|
||||
}
|
||||
if (action.help) {
|
||||
action.help = markup(action.help);
|
||||
}
|
||||
return this.actionService.doAction(action, { onClose: this._onClose.bind(this) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="stock.ForecastedButtons">
|
||||
<button title="Replenish" t-on-click="_onClickReplenish"
|
||||
class="o_forecasted_replenish_btn btn btn-primary">
|
||||
Replenish
|
||||
</button>
|
||||
<button title="Update Quantity" t-on-click="_onClickUpdateQuantity"
|
||||
class="o_forecasted_update_qty_btn btn btn-secondary">
|
||||
Update Quantity
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
242
frontend/stock/static/src/stock_forecasted/forecasted_details.js
Normal file
242
frontend/stock/static/src/stock_forecasted/forecasted_details.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import { formatFloat } from "@web/views/fields/formatters";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class ForecastedDetails extends Component {
|
||||
static template = "stock.ForecastedDetails";
|
||||
static props = { docs: Object, openView: Function, reloadReport: Function };
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this._groupLines();
|
||||
this._prepareLines();
|
||||
this._prepareData();
|
||||
this._mergeLines();
|
||||
|
||||
this._formatFloat = (num) => {
|
||||
return formatFloat(num, { digits: this.props.docs.precision });
|
||||
};
|
||||
}
|
||||
|
||||
async _reserve(move_id){
|
||||
await this.orm.call(
|
||||
'stock.forecasted_product_product',
|
||||
'action_reserve_linked_picks',
|
||||
[move_id],
|
||||
);
|
||||
this.props.reloadReport();
|
||||
}
|
||||
|
||||
async _unreserve(move_id){
|
||||
await this.orm.call(
|
||||
'stock.forecasted_product_product',
|
||||
'action_unreserve_linked_picks',
|
||||
[move_id],
|
||||
);
|
||||
this.props.reloadReport();
|
||||
}
|
||||
|
||||
async _onClickChangePriority(modelName, record) {
|
||||
const value = record.priority == "0" ? "1" : "0";
|
||||
|
||||
await this.orm.call(modelName, "write", [[record.id], { priority: value }]);
|
||||
this.props.reloadReport();
|
||||
}
|
||||
|
||||
_onHandCondition(line){
|
||||
return !line.document_in && !line.in_transit && line.replenishment_filled && line.document_out;
|
||||
}
|
||||
|
||||
_reconciledCondition(line){
|
||||
return line.document_in && !line.in_transit && line.replenishment_filled && line.document_out;
|
||||
}
|
||||
|
||||
_freeStockCondition(line){
|
||||
return !line.document_in && !line.in_transit && line.replenishment_filled && !line.document_out;
|
||||
}
|
||||
|
||||
_notAvailableCondition(line){
|
||||
return !line.document_in && !line.in_transit && !line.replenishment_filled && line.document_out;
|
||||
}
|
||||
|
||||
//Extend this to add new lines grouping
|
||||
_groupLines(){
|
||||
this._groupLinesByProduct();
|
||||
this._groupOnHandLinesByProduct();
|
||||
this._groupReconciledLinesByProduct();
|
||||
this._groupFreeStockLinesByProduct();
|
||||
this._groupNotAvailableLinesByProduct();
|
||||
}
|
||||
|
||||
_groupLinesByProduct() {
|
||||
this.LinesPerProduct = {};
|
||||
for (const line of this.props.docs.lines) {
|
||||
const key = line.product.id;
|
||||
(this.LinesPerProduct[key] ??= []).push(line);
|
||||
}
|
||||
}
|
||||
|
||||
_groupOnHandLinesByProduct() {
|
||||
this.OnHandLinesPerProduct = {};
|
||||
for (const line of this.props.docs.lines) {
|
||||
if (this._onHandCondition(line)) {
|
||||
const key = line.product.id;
|
||||
(this.OnHandLinesPerProduct[key] ??= []).push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_groupReconciledLinesByProduct() {
|
||||
this.ReconciledLinesPerProduct = {};
|
||||
for (const line of this.props.docs.lines) {
|
||||
if (this._onHandCondition(line)) {
|
||||
const key = line.product.id;
|
||||
(this.ReconciledLinesPerProduct[key] ??= []).push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_groupNotAvailableLinesByProduct() {
|
||||
this.NotAvailableLinesPerProduct = {};
|
||||
for (const line of this.props.docs.lines) {
|
||||
if (this._notAvailableCondition(line)) {
|
||||
const key = line.product.id;
|
||||
(this.NotAvailableLinesPerProduct[key] ??= []).push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_groupFreeStockLinesByProduct() {
|
||||
this.FreeStockLinesPerProduct = {};
|
||||
for (const line of this.props.docs.lines) {
|
||||
if (this._freeStockCondition(line) && line?.removal_date !== -1) {
|
||||
const key = line.product.id;
|
||||
(this.FreeStockLinesPerProduct[key] ??= []).push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_prepareLines(){
|
||||
if (this.multipleProducts) {
|
||||
this.props.docs.lines.sort((a, b) => (a.product.id || 0) - (b.product.id || 0));
|
||||
}
|
||||
}
|
||||
|
||||
_prepareData(){
|
||||
this.OnHandTotalQty = Object.fromEntries(
|
||||
Object.entries(this.OnHandLinesPerProduct).map(([id, lines]) => [
|
||||
id,
|
||||
lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
])
|
||||
);
|
||||
this.AvailableOnHandTotalQty = Object.fromEntries(
|
||||
Object.entries(this.OnHandLinesPerProduct).map(([id, lines]) => [
|
||||
id,
|
||||
lines.reduce((sum, line) => sum + (line.reservation ? 0 : line.quantity), 0),
|
||||
])
|
||||
);
|
||||
for (const productId of this.productIds){
|
||||
if (!(productId in this.FreeStockLinesPerProduct) || !(productId in this.LinesPerProduct)){
|
||||
continue;
|
||||
}
|
||||
const lines = this.FreeStockLinesPerProduct[productId]
|
||||
if (this.LinesPerProduct[productId].length > 1 && lines.length == 1 && lines[0]?.quantity === 0 ){
|
||||
const removeIndex = this.lines.indexOf(lines[0]);
|
||||
this.lines.splice(removeIndex,1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mergeLines(){
|
||||
let lines = this.lines;
|
||||
this.mergesLinesData = {};
|
||||
let lastIndex = 0;
|
||||
for(let i = 0; i < lines.length-1; i++){
|
||||
const line = lines[i];
|
||||
const nextLine = lines[i + 1];
|
||||
if (line.product.id != nextLine.product.id || !this._sameLineRule(line, nextLine)) {
|
||||
lastIndex = i+1;
|
||||
continue;
|
||||
}
|
||||
if (!this.mergesLinesData[lastIndex]){
|
||||
this.mergesLinesData[lastIndex] = {
|
||||
rowcount: 1,
|
||||
tot_qty: line.quantity,
|
||||
};
|
||||
}
|
||||
this.mergesLinesData[lastIndex].rowcount += 1;
|
||||
this.mergesLinesData[lastIndex].tot_qty += nextLine.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
_sameLineRule(line, nextLine){
|
||||
const OnHand = this.OnHandLinesPerProduct[line.product.id] || [];
|
||||
const NotAvailable = this.NotAvailableLinesPerProduct[line.product.id] || [];
|
||||
return this.sameDocumentIn(line, nextLine) || (OnHand.includes(line) && OnHand.includes(nextLine)) || (NotAvailable.includes(line) && NotAvailable.includes(nextLine));
|
||||
}
|
||||
|
||||
displayReserve(line){
|
||||
let splittedLine = true;
|
||||
if(this.line_index - 1 >= 0){
|
||||
const previousLine = this.lines[this.line_index - 1];
|
||||
const sameProduct = this.line.product.id == previousLine.product.id;
|
||||
const isOnHandSplittedLine = this.OnHandLinesPerProduct[line.product.id] && this.OnHandLinesPerProduct[line.product.id].some(l => this.sameDocumentOut(l, line))
|
||||
const isReconciledSplittedLine = this.ReconciledLinesPerProduct[line.product.id] && !this.isReconciled(line) && this.ReconciledLinesPerProduct[line.product.id].some(l => this.sameDocumentOut(l, line))
|
||||
splittedLine = sameProduct && (this.sameDocumentOut(line, previousLine) || isOnHandSplittedLine || isReconciledSplittedLine);
|
||||
}
|
||||
const hasFreeStock = this.props.docs.product[line.product.id].free_qty > 0;
|
||||
return this.props.docs.user_can_edit_pickings && !line.in_transit && this.canReserveOperation(line) &&
|
||||
(this.isOnHand(line) || (hasFreeStock && !splittedLine));
|
||||
}
|
||||
|
||||
canReserveOperation(line){
|
||||
return line.move_out?.picking_id;
|
||||
}
|
||||
|
||||
futureVirtualAvailable(line) {
|
||||
const product = this.props.docs.product[line.product.id]
|
||||
return product.virtual_available + product.qty.in - product.qty.out;
|
||||
}
|
||||
|
||||
sameDocumentIn(line1, line2){
|
||||
return this._sameDocument(line1, line2, 'document_in');
|
||||
}
|
||||
|
||||
sameDocumentOut(line1, line2){
|
||||
return this._sameDocument(line1, line2, 'document_out');
|
||||
}
|
||||
|
||||
_sameDocument(line1, line2, docField) {
|
||||
return (
|
||||
line1[docField] && line2[docField] &&
|
||||
line1[docField].id === line2[docField].id &&
|
||||
line1[docField]._name === line2[docField]._name &&
|
||||
line1[docField].name === line2[docField].name
|
||||
);
|
||||
}
|
||||
|
||||
isOnHand(line){
|
||||
return this.OnHandLinesPerProduct[line.product.id] && this.OnHandLinesPerProduct[line.product.id].includes(this.lines[this.line_index]);
|
||||
}
|
||||
|
||||
isReconciled(line){
|
||||
return this.ReconciledLinesPerProduct[line.product.id] && this.ReconciledLinesPerProduct[line.product.id].includes(this.lines[this.line_index]);
|
||||
}
|
||||
|
||||
get freeStockLabel() {
|
||||
return _t('Free Stock');
|
||||
}
|
||||
|
||||
get lines() {
|
||||
return this.props.docs.lines;
|
||||
}
|
||||
|
||||
get multipleProducts() {
|
||||
return this.props.docs.multiple_product;
|
||||
}
|
||||
|
||||
get productIds(){
|
||||
return Object.keys(this.props.docs.product).map(Number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="stock.ForecastedDetails">
|
||||
<table class="table table-bordered bg-view o_forecasted_details_table">
|
||||
<thead class="o_forecasted_details_main_header">
|
||||
<tr class="bg-light border-top-0">
|
||||
<th/>
|
||||
<th scope="col">Available</th>
|
||||
<th scope="col" class="text-end">Outgoing</th>
|
||||
<th scope="col">Used by</th>
|
||||
<th scope="col"></th> <!-- Action Button -->
|
||||
<th scope="col">Delivery Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-set="last_line_index" t-value="lines.length - 1"/>
|
||||
<t t-foreach="lines" t-as="line" t-key="line_index">
|
||||
<t t-set="currentProduct" t-value="props.docs.product[line.product.id]"/>
|
||||
<t t-if="multipleProducts and (line_index === 0 or line.product.id !== lines[line_index - 1].product.id)">
|
||||
<tr class="o_forecasted_product_header">
|
||||
<td class="border-0 p-0">
|
||||
<button class="btn btn-sm border-0"
|
||||
data-bs-toggle="collapse"
|
||||
t-attf-data-bs-target=".collapseGroup_#{line.product.id}"
|
||||
aria-expanded="true" t-attf-aria-controls=".collapseGroup_#{line.product.id}">
|
||||
<i class="icon fa"/>
|
||||
</button>
|
||||
</td>
|
||||
<td colspan="5" class="fw-bold text-uppercase border-0">
|
||||
<span t-out="line.product.display_name"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr t-attf-class="#{line.is_late and 'table-warning' or ''} #{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}" >
|
||||
<t t-if="skip_row > 0" t-set="skip_row" t-value="skip_row - 1"/>
|
||||
<t t-if="!skip_row or skip_row == 0">
|
||||
<t t-if="mergesLinesData[line_index]" t-set="skip_row" t-value="mergesLinesData[line_index]['rowcount']"/>
|
||||
<td t-attf-rowspan="#{skip_row > 0 and skip_row}"/>
|
||||
<td t-attf-rowspan="#{skip_row > 0 and skip_row}">
|
||||
<t t-if="line.document_in">
|
||||
<a t-if="line.document_in"
|
||||
href="#"
|
||||
t-on-click.prevent="() => this.props.openView(line.document_in._name, 'form', line.document_in.id)"
|
||||
t-out="line.document_in.name"
|
||||
class="fw-bold"/>
|
||||
<span t-if="line.receipt_date">:
|
||||
<t t-out="_formatFloat(skip_row > 0 ? mergesLinesData[line_index].tot_qty : line.quantity)"/> <t t-out="line.uom_id.display_name"/> expected on <t t-out="line.receipt_date"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="line.in_transit">
|
||||
<t t-if="line.move_out">
|
||||
<span>Stock In Transit</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span>Free Stock in Transit</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="line.replenishment_filled">
|
||||
<t name="onHand_cell" t-if="line.document_out">
|
||||
Stock To Reserve: <t t-out="_formatFloat(OnHandTotalQty[line.product.id])"/> <t t-out="line.uom_id.display_name"/>
|
||||
</t>
|
||||
<t name="freeStock_cell" t-else="" t-out="freeStockLabel"/>
|
||||
</t>
|
||||
<span t-else="" class="text-muted">Not Available</span>
|
||||
</td>
|
||||
</t>
|
||||
<td name="quantity_cell" class="text-end"><t t-attf-class="#{(!line.replenishment_filled and 'text-danger')" t-out="_formatFloat(line.quantity)"/> <t t-out="line.uom_id.display_name"/></td>
|
||||
<td class="border-end-0 o_forecasted_details_line_button" t-attf-class="#{(!line.replenishment_filled and 'table-danger') or (line.is_matched and 'table-info')}" name="usedby_cell">
|
||||
<button t-if="line.move_out and line.move_out.picking_id"
|
||||
t-attf-class="o_priority o_priority_star me-1 fa fa-star#{line.move_out.picking_id.priority=='1' ? '' : '-o'}"
|
||||
t-on-click="() => this._onClickChangePriority('stock.picking', line.move_out.picking_id)"
|
||||
name="change_priority_link"/>
|
||||
<a t-if="line.document_out"
|
||||
href="#"
|
||||
t-attf-class="#{line.is_matched and 'fst-italic'}"
|
||||
t-on-click.prevent="() => this.props.openView(line.document_out._name, 'form', line.document_out.id)"
|
||||
t-out="line.document_out.name"
|
||||
class="fw-bold"/>
|
||||
</td>
|
||||
<td class="p-0 text-center border-start-0">
|
||||
<t t-if="displayReserve(line)">
|
||||
<a t-if="line.reservation"
|
||||
href="#"
|
||||
name="unreserve_link"
|
||||
t-on-click="() => this._unreserve(line.move_out.id)">
|
||||
Unreserve
|
||||
</a>
|
||||
<button t-elif="line.replenishment_filled or AvailableOnHandTotalQty[line.product.id] > 0"
|
||||
class="btn btn-sm btn-secondary"
|
||||
name="reserve_link"
|
||||
t-on-click="() => this._reserve(line.move_out.id)">
|
||||
Reserve
|
||||
<t t-if="AvailableOnHandTotalQty[line.product.id] > 0 and !isOnHand(line)">
|
||||
(<t t-out="_formatFloat(Math.min(AvailableOnHandTotalQty[line.product.id], line.quantity))"/> <t t-out="line.uom_id.display_name"/>)
|
||||
</t>
|
||||
</button>
|
||||
</t>
|
||||
</td>
|
||||
<td t-out="line.delivery_date or ''"
|
||||
t-attf-class="#{line.delivery_late and 'text-danger'}"/>
|
||||
</tr>
|
||||
<t t-if="line_index === last_line_index or line.product.id !== lines[line_index + 1].product.id">
|
||||
<!-- Check in progress with DALA
|
||||
<tr t-if="! multipleProducts and this.props.docs.qty_to_order" t-attf-class="#{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}">
|
||||
<td/>
|
||||
<td>To Order</td>
|
||||
<td class="text-end">
|
||||
<span class="text-muted" t-out="this.props.docs.lead_horizon_date"/>
|
||||
<span ><t t-out="_formatFloat(this.props.docs.qty_to_order)"/> <t t-out="line.uom_id.display_name"/></span>
|
||||
</td>
|
||||
</tr> -->
|
||||
<tr class="o_forecasted_row fw-bold table-secondary" t-attf-class="#{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}">
|
||||
<td/>
|
||||
<td class="border-end-0">Forecasted Inventory</td>
|
||||
<td class="text-end border-start-0 border-end-0" t-attf-class="#{currentProduct.virtual_available <= 0 and 'text-danger' or 'text-success'}">
|
||||
<t t-out="_formatFloat(currentProduct.virtual_available)"/> <t t-out="line.uom_id.display_name"/>
|
||||
</td>
|
||||
<td class="border-start-0" colspan="3"/>
|
||||
</tr>
|
||||
<tr t-if="currentProduct.draft_picking_qty.in" name="draft_picking_in" t-attf-class="#{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}">
|
||||
<td/>
|
||||
<td>Incoming Draft Transfer</td>
|
||||
<td class="text-end">
|
||||
<t t-out="_formatFloat(currentProduct.draft_picking_qty.in)"/> <t t-out="line.uom_id.display_name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="currentProduct.draft_picking_qty.out" name="draft_picking_out" t-attf-class="#{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}">
|
||||
<td/>
|
||||
<td>Outgoing Draft Transfer</td>
|
||||
<td class="text-end">
|
||||
<t t-out="_formatFloat(-currentProduct.draft_picking_qty.out)"/> <t t-out="line.uom_id.display_name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="o_forecasted_row fw-bold table-secondary" t-attf-class="#{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}">
|
||||
<td/>
|
||||
<td class="border-end-0">Forecasted with Pending</td>
|
||||
<td class="text-end border-start-0 border-end-0" t-attf-class="#{futureVirtualAvailable(line) <= 0 and 'text-danger' or 'text-success'}">
|
||||
<t t-out="_formatFloat(futureVirtualAvailable(line))"/> <t t-out="line.uom_id.display_name"/>
|
||||
</td>
|
||||
<td class="border-start-0" colspan="3"/>
|
||||
</tr>
|
||||
</t>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { GraphRenderer } from "@web/views/graph/graph_renderer";
|
||||
import { graphView } from "@web/views/graph/graph_view";
|
||||
|
||||
export class StockForecastedGraphRenderer extends GraphRenderer {
|
||||
static template = "stock.ForecastedGraphRenderer";
|
||||
|
||||
getLineChartData() {
|
||||
const data = super.getLineChartData();
|
||||
// Ensure the line chart is stepped
|
||||
data.datasets.forEach((dataset) => {
|
||||
dataset.stepped = true;
|
||||
dataset.spanGaps = true;
|
||||
});
|
||||
if (data.datasets.length) {
|
||||
const dataset_length = data.datasets[0].data.length;
|
||||
for(let i = dataset_length-2; i > 0; i--) { // i=0 and i=last are always preserved
|
||||
let skipData = data.datasets.every(d => d.data[i] == d.data[i-1]);
|
||||
if (skipData){
|
||||
data.datasets.forEach((dataset) => {
|
||||
dataset.data[i] = null; // Mark as null to indicate it can be skipped
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
export const StockForecastedGraphView = {
|
||||
...graphView,
|
||||
Renderer: StockForecastedGraphRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("stock_forecasted_graph", StockForecastedGraphView);
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="stock.ForecastedGraphRenderer" t-inherit="web.GraphRenderer" t-inherit-mode="primary">
|
||||
<xpath expr="//canvas[@t-ref='canvas']" position="attributes">
|
||||
<attribute name="height">300</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { formatFloat } from "@web/views/fields/formatters";
|
||||
import { Component, markup } from "@odoo/owl";
|
||||
|
||||
export class ForecastedHeader extends Component {
|
||||
static template = "stock.ForecastedHeader";
|
||||
static props = { docs: Object, openView: Function };
|
||||
|
||||
setup(){
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.tooltip = useService("tooltip");
|
||||
|
||||
this._formatFloat = (num) => formatFloat(num, { digits: this.props.docs.precision });
|
||||
}
|
||||
|
||||
async _onClickInventory(){
|
||||
const productIds = this.props.docs.product_variants_ids;
|
||||
const action = await this.orm.call('product.product', 'action_open_quants', [productIds]);
|
||||
if (action.help) {
|
||||
action.help = markup(action.help);
|
||||
}
|
||||
return this.action.doAction(action);
|
||||
}
|
||||
|
||||
get products() {
|
||||
return this.props.docs.product;
|
||||
}
|
||||
|
||||
get leadTime() {
|
||||
if (!this.products || this.products.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const productsArray = Object.values(this.products || {});
|
||||
const product = productsArray.reduce((minProduct, p) => {
|
||||
if (
|
||||
!minProduct ||
|
||||
(p.leadtime && p.leadtime.total_delay < (minProduct.leadtime?.total_delay ?? Infinity))
|
||||
) {
|
||||
return p;
|
||||
}
|
||||
return minProduct;
|
||||
}, null);
|
||||
const today = new Date(Date.now());
|
||||
product.leadtime["today"] = today.toLocaleDateString();
|
||||
product.leadtime["earliestPossibleArrival"] = this.addDays(today, product.leadtime.total_delay);
|
||||
return product.leadtime;
|
||||
}
|
||||
|
||||
get leadTimeShort() {
|
||||
let short = " " + (this.leadTime.total_delay) + " day(s)";
|
||||
if (this.leadTime.total_delay != 0) {
|
||||
short += " (" + this.leadTime.earliestPossibleArrival + ")";
|
||||
}
|
||||
return short;
|
||||
}
|
||||
|
||||
get quantityOnHand() {
|
||||
return Object.values(this.products).reduce((sum, product) => sum + product.quantity_on_hand, 0);
|
||||
}
|
||||
|
||||
get incomingQty() {
|
||||
return Object.values(this.products).reduce((sum, product) => sum + product.incoming_qty, 0);
|
||||
}
|
||||
|
||||
get outgoingQty() {
|
||||
return Object.values(this.products).reduce((sum, product) => sum + product.outgoing_qty, 0);
|
||||
}
|
||||
|
||||
get virtualAvailable() {
|
||||
return Object.values(this.products).reduce((sum, product) => sum + product.virtual_available, 0);
|
||||
}
|
||||
|
||||
get uom() {
|
||||
return Object.values(this.products)[0].uom;
|
||||
}
|
||||
|
||||
addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result.toLocaleDateString();
|
||||
}
|
||||
|
||||
toJsonString(obj) {
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="stock.ForecastedHeader">
|
||||
<div class="d-flex flex-wrap pb-1 justify-content-between">
|
||||
<div class="o_product_name">
|
||||
<h3 name="product_name">
|
||||
<t t-if="props.docs.product_templates">
|
||||
<t t-foreach="props.docs.product_templates" t-as="product_template" t-key='product_template.id'>
|
||||
<a href="#"
|
||||
t-on-click.prevent="() => this.props.openView('product.template', 'form', product_template.id)"
|
||||
t-out="product_template.display_name"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-elif="props.docs.product_variants">
|
||||
<t t-foreach="props.docs.product_variants" t-as="product_variant" t-key="product_variant.id">
|
||||
<a href="#"
|
||||
t-on-click.prevent="() => this.props.openView('product.product', 'form', product_variant.id)"
|
||||
t-out="product_variant.display_name"/>
|
||||
</t>
|
||||
</t>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-auto text-center" name="on_hand">
|
||||
<div class="h3">
|
||||
<a href="#"
|
||||
t-on-click.prevent="() => this._onClickInventory()"
|
||||
t-out="_formatFloat(this.quantityOnHand)"/>
|
||||
</div>
|
||||
<div>On Hand</div>
|
||||
</div>
|
||||
<div class="h3 col-md-auto text-center">+</div>
|
||||
<div t-attf-class="col-md-auto text-center #{props.docs.incoming_qty}">
|
||||
<div class="h3">
|
||||
<t t-out="_formatFloat(this.incomingQty)"/>
|
||||
</div>
|
||||
<div>Incoming</div>
|
||||
</div>
|
||||
<div class="h3 col-md-auto text-center">-</div>
|
||||
<div t-attf-class="col-md-auto text-center #{props.docs.outgoing_qty}">
|
||||
<div class="h3">
|
||||
<t t-out="_formatFloat(this.outgoingQty)"/>
|
||||
</div>
|
||||
<div>Outgoing</div>
|
||||
</div>
|
||||
<div class="h3 col-md-auto text-center">=</div>
|
||||
<div t-attf-class="col-md-auto text-center #{props.docs.virtual_available < 0 and 'text-danger'}" name="forecasted_value">
|
||||
<div class="h3">
|
||||
<span t-out="_formatFloat(this.virtualAvailable)"/>
|
||||
<span t-out="' ' + this.uom" groups="uom.group_uom"/>
|
||||
</div>
|
||||
<div>Forecasted</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap pb-1 pt-2 justify-content-end">
|
||||
<div class="row">
|
||||
<h6>Time to replenish:
|
||||
<span data-tooltip-template="LeadTimeTooltip" t-att-data-tooltip-info="this.toJsonString(this.leadTime)">
|
||||
<span class="text-info" t-out="this.leadTimeShort"/>
|
||||
</span>
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="LeadTimeTooltip">
|
||||
<h6>Lead Time Information</h6>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Today</td>
|
||||
<td><span t-out="today"/></td>
|
||||
</tr>
|
||||
<t t-foreach="details" t-as="detail" t-key="detail[0]">
|
||||
<tr>
|
||||
<td><span t-out="detail[0]"/></td>
|
||||
<td><span t-out="detail[1]"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<tr>
|
||||
<td>Earliest Possible Arrival</td>
|
||||
<td><span t-out="earliestPossibleArrival"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, onWillStart } from "@odoo/owl";
|
||||
|
||||
export class ForecastedWarehouseFilter extends Component {
|
||||
static template = "stock.ForecastedWarehouseFilter";
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = { action: Object, setWarehouseInContext: Function, warehouses: Array };
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.context = this.props.action.context;
|
||||
this.warehouses = this.props.warehouses;
|
||||
onWillStart(this.onWillStart)
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
this.displayWarehouseFilter = (this.warehouses.length > 1);
|
||||
}
|
||||
|
||||
_onSelected(id){
|
||||
this.props.setWarehouseInContext(Number(id));
|
||||
}
|
||||
|
||||
get activeWarehouse(){
|
||||
return this.context.warehouse_id ? this.warehouses.find((w) => w.id == this.context.warehouse_id) : this.warehouses[0];
|
||||
}
|
||||
|
||||
get warehousesItems() {
|
||||
return this.warehouses.map(warehouse => ({
|
||||
id: warehouse.id,
|
||||
label: warehouse.name,
|
||||
onSelected: () => this._onSelected(warehouse.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="stock.ForecastedWarehouseFilter">
|
||||
<div t-if="displayWarehouseFilter" class="btn-group">
|
||||
<Dropdown menuClass="o_filter_menu" items="warehousesItems">
|
||||
<button class="btn btn-secondary">
|
||||
<span class="fa fa-home"/> Warehouse: <t t-out="activeWarehouse.name"/>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
137
frontend/stock/static/src/stock_forecasted/stock_forecasted.js
Normal file
137
frontend/stock/static/src/stock_forecasted/stock_forecasted.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { View } from "@web/views/view";
|
||||
import { ControlPanel } from "@web/search/control_panel/control_panel";
|
||||
|
||||
import { ForecastedButtons } from "./forecasted_buttons";
|
||||
import { ForecastedDetails } from "./forecasted_details";
|
||||
import { ForecastedHeader } from "./forecasted_header";
|
||||
import { ForecastedWarehouseFilter } from "./forecasted_warehouse_filter";
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
|
||||
export class StockForecasted extends Component {
|
||||
static template = "stock.Forecasted";
|
||||
static components = {
|
||||
ControlPanel,
|
||||
ForecastedButtons,
|
||||
ForecastedWarehouseFilter,
|
||||
ForecastedHeader,
|
||||
View,
|
||||
ForecastedDetails,
|
||||
};
|
||||
static props = { ...standardActionServiceProps };
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
|
||||
this.context = useState(this.props.action.context);
|
||||
this.productId = this.context.active_id;
|
||||
this.resModel = this.context.active_model;
|
||||
this.title = this.props.action.name || _t("Forecasted Report");
|
||||
if(!this.context.active_id){
|
||||
this.context.active_id = this.props.action.params.active_id;
|
||||
this.reloadReport();
|
||||
}
|
||||
this.warehouses = useState([]);
|
||||
|
||||
onWillStart(this._getReportValues);
|
||||
}
|
||||
|
||||
async _getReportValues() {
|
||||
await this._getResModel();
|
||||
const isTemplate = !this.resModel || this.resModel === 'product.template';
|
||||
this.reportModelName = `stock.forecasted_product_${isTemplate ? "template" : "product"}`;
|
||||
this.warehouses.splice(0, this.warehouses.length);
|
||||
this.warehouses.push(...await this.orm.searchRead('stock.warehouse', [],['id', 'name', 'code']));
|
||||
if (!this.context.warehouse_id) {
|
||||
this.updateWarehouse(this.warehouses[0].id);
|
||||
}
|
||||
const reportValues = await this.orm.call(this.reportModelName, "get_report_values", [], {
|
||||
context: this.context,
|
||||
docids: [this.productId],
|
||||
});
|
||||
this.docs = {
|
||||
...reportValues.docs,
|
||||
...reportValues.precision,
|
||||
lead_horizon_date: this.context.lead_horizon_date,
|
||||
qty_to_order: this.context.qty_to_order,
|
||||
};
|
||||
}
|
||||
|
||||
async _getResModel(){
|
||||
this.resModel = this.context.active_model || this.context.params?.active_model;
|
||||
//Following is used as a fallback when the forecast is not called by an action but through browser's history
|
||||
if (!this.resModel) {
|
||||
let resModel = this.props.action.res_model;
|
||||
if (resModel) {
|
||||
if (/^\d+$/.test(resModel)) {
|
||||
// legacy action definition where res_model is the model id instead of name
|
||||
const actionModel = await this.orm.read('ir.model', [Number(resModel)], ['model']);
|
||||
resModel = actionModel[0]?.model;
|
||||
}
|
||||
this.resModel = resModel;
|
||||
} else if (this.props.action._originalAction) {
|
||||
const originalContextAction = JSON.parse(this.props.action._originalAction).context;
|
||||
if (typeof originalContextAction === "string") {
|
||||
this.resModel = JSON.parse(originalContextAction.replace(/'/g, '"')).active_model;
|
||||
} else if (originalContextAction) {
|
||||
this.resModel = originalContextAction.active_model;
|
||||
}
|
||||
}
|
||||
this.context.active_model = this.resModel;
|
||||
}
|
||||
}
|
||||
|
||||
async updateWarehouse(id) {
|
||||
const hasPreviousValue = this.context.warehouse_id !== undefined;
|
||||
this.context.warehouse_id = id;
|
||||
if (hasPreviousValue) {
|
||||
await this.reloadReport();
|
||||
}
|
||||
}
|
||||
|
||||
async reloadReport() {
|
||||
const actionRequest = {
|
||||
id: this.props.action.id,
|
||||
type: "ir.actions.client",
|
||||
tag: "stock_forecasted",
|
||||
context: this.context,
|
||||
name: this.title,
|
||||
};
|
||||
const options = { stackPosition: "replaceCurrentAction" };
|
||||
return this.action.doAction(actionRequest, options);
|
||||
}
|
||||
|
||||
get graphDomain() {
|
||||
const domain = [
|
||||
["state", "=", "forecast"],
|
||||
["warehouse_id", "=", this.context.warehouse_id],
|
||||
];
|
||||
if (this.resModel === "product.template") {
|
||||
domain.push(["product_tmpl_id", "=", this.productId]);
|
||||
} else if (this.resModel === "product.product") {
|
||||
domain.push(["product_id", "=", this.productId]);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
get graphInfo() {
|
||||
return { noContentHelp: _t("Try to add some incoming or outgoing transfers.") };
|
||||
}
|
||||
|
||||
async openView(resModel, view, resId=false, domain = false) {
|
||||
const action = {
|
||||
type: "ir.actions.act_window",
|
||||
res_model: resModel,
|
||||
views: [[false, view]],
|
||||
view_mode: view,
|
||||
res_id: resId,
|
||||
domain: domain,
|
||||
};
|
||||
return this.action.doAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("stock_forecasted", StockForecasted);
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<div t-name="stock.Forecasted" class="o_action o_stock_forecast">
|
||||
<ControlPanel>
|
||||
<t t-set-slot="layout-buttons">
|
||||
<ForecastedButtons action="props.action" reloadReport.bind="reloadReport" resModel="resModel"/>
|
||||
</t>
|
||||
<t t-set-slot="layout-actions">
|
||||
<div class="btn-group o_search_options position-static" role="search">
|
||||
<ForecastedWarehouseFilter action="props.action" warehouses="warehouses" setWarehouseInContext.bind="updateWarehouse"/>
|
||||
</div>
|
||||
</t>
|
||||
</ControlPanel>
|
||||
<div class="o-content pt-3 container-fluid overflow-auto o_stock_forecasted_page">
|
||||
<ForecastedHeader docs="docs" openView.bind="openView"/>
|
||||
<t t-if="context.warehouse_id">
|
||||
<View type="'graph'"
|
||||
viewId="stock_report_view_graph"
|
||||
resModel="'report.stock.quantity'"
|
||||
domain="graphDomain"
|
||||
display="{controlPanel: false}"
|
||||
context="{fill_temporal: false}"
|
||||
info="graphInfo"
|
||||
useSampleModel="true"
|
||||
/>
|
||||
</t>
|
||||
<ForecastedDetails docs="docs" openView.bind="openView" reloadReport.bind="reloadReport"/>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
19
frontend/stock/static/src/stock_warehouse_service.js
Normal file
19
frontend/stock/static/src/stock_warehouse_service.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { UPDATE_METHODS } from "@web/core/orm_service";
|
||||
import { rpcBus } from "@web/core/network/rpc";
|
||||
|
||||
registry.category("services").add("stock_warehouse", {
|
||||
dependencies: ["action"],
|
||||
start(env, { action }) {
|
||||
rpcBus.addEventListener("RPC:RESPONSE", (ev) => {
|
||||
const { data, error } = ev.detail;
|
||||
const { model, method } = data.params;
|
||||
if (!error && model === "stock.warehouse") {
|
||||
if (UPDATE_METHODS.includes(method) && !browser.localStorage.getItem("running_tour")) {
|
||||
action.doAction("reload_context");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { DynamicRecordList } from "@web/model/relational_model/dynamic_record_list";
|
||||
import { RelationalModel } from "@web/model/relational_model/relational_model";
|
||||
|
||||
export class InventoryReportListModel extends RelationalModel {
|
||||
/**
|
||||
* Override
|
||||
*/
|
||||
setup(params, { action, dialog, notification, rpc, user, view, company }) {
|
||||
// model has not created any record yet
|
||||
this._lastCreatedRecordId;
|
||||
return super.setup(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function called when a record has been _load (after saved).
|
||||
* We need to detect when the user added to the list a quant which already exists
|
||||
* (see stock.quant.create), either already loaded or not, to warn the user
|
||||
* the quant was updated.
|
||||
* This is done by checking :
|
||||
* - the record id against the '_lastCreatedRecordId' on model
|
||||
* - the create_date against the write_date (both are equal for newly created records).
|
||||
*
|
||||
*/
|
||||
async _updateSimilarRecords(reloadedRecord, serverValues) {
|
||||
if (this.config.isMonoRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
const justCreated = reloadedRecord.id == this._lastCreatedRecordId;
|
||||
if (justCreated && serverValues.create_date !== serverValues.write_date) {
|
||||
this.notification.add(
|
||||
_t(
|
||||
"You tried to create a record that already exists. The existing record was modified instead."
|
||||
),
|
||||
{ title: _t("This record already exists") }
|
||||
);
|
||||
const duplicateRecords = this.root.records.filter(
|
||||
(record) => record.resId === reloadedRecord.resId && record.id !== reloadedRecord.id
|
||||
);
|
||||
if (duplicateRecords.length > 0) {
|
||||
/* more than 1 'resId' record loaded in view (user added an already loaded record) :
|
||||
* - both have been updated
|
||||
* - remove the current record (the added one)
|
||||
*/
|
||||
await this.root._removeRecords([reloadedRecord.id]);
|
||||
for (const record of duplicateRecords) {
|
||||
record._applyValues(serverValues);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
super._updateSimilarRecords(...arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InventoryReportListDynamicRecordList extends DynamicRecordList {
|
||||
/**
|
||||
* Override
|
||||
*/
|
||||
async addNewRecord() {
|
||||
const record = await super.addNewRecord(...arguments);
|
||||
// keep created record id on model
|
||||
record.model._lastCreatedRecordId = record.id;
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
InventoryReportListModel.DynamicRecordList = InventoryReportListDynamicRecordList;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { InventoryReportListModel } from "./inventory_report_list_model";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const InventoryReportListView = {
|
||||
...listView,
|
||||
Model: InventoryReportListModel,
|
||||
};
|
||||
|
||||
registry.category("views").add('inventory_report_list', InventoryReportListView);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class AddPackageListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.addDialog = useOwnedDialogs();
|
||||
this.pickingId = this.props.list.context.picking_ids?.length
|
||||
? this.props.list.context.picking_ids[0]
|
||||
: 0;
|
||||
this.locationId = this.props.list.context.location_id || 0;
|
||||
this.canAddEntirePacks = this.props.list.context?.can_add_entire_packs;
|
||||
}
|
||||
|
||||
get displayRowCreates() {
|
||||
return this.canAddEntirePacks;
|
||||
}
|
||||
|
||||
async add(params) {
|
||||
await this.onClickAdd();
|
||||
}
|
||||
|
||||
async onClickAdd() {
|
||||
const domain = [];
|
||||
if (this.locationId) {
|
||||
domain.push(["location_id", "child_of", this.locationId]);
|
||||
}
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: _t("Select Packages to Move"),
|
||||
noCreate: true,
|
||||
multiSelect: true,
|
||||
resModel: "stock.package",
|
||||
domain,
|
||||
context: {
|
||||
list_view_ref: "stock.stock_package_view_add_list",
|
||||
},
|
||||
onSelected: async (resIds) => {
|
||||
if (resIds.length) {
|
||||
const done = await this.orm.call("stock.picking", "action_add_entire_packs", [
|
||||
[this.pickingId],
|
||||
resIds,
|
||||
]);
|
||||
if (done) {
|
||||
await this.actionService.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "soft_reload",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("views").add("stock_add_package_list_view", {
|
||||
...listView,
|
||||
Renderer: AddPackageListRenderer,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { listView } from '@web/views/list/list_view';
|
||||
import { registry } from "@web/core/registry";
|
||||
import { StockReportSearchModel } from "../search/stock_report_search_model";
|
||||
import { StockReportSearchPanel } from '../search/stock_report_search_panel';
|
||||
|
||||
|
||||
export const StockReportListView = {
|
||||
...listView,
|
||||
SearchModel: StockReportSearchModel,
|
||||
SearchPanel: StockReportSearchPanel,
|
||||
};
|
||||
|
||||
registry.category("views").add("stock_report_list_view", StockReportListView);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
import { ProductNameAndDescriptionListRendererMixin } from "@product/product_name_and_description/product_name_and_description";
|
||||
import { user } from "@web/core/user";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
|
||||
import { onWillStart } from "@odoo/owl";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class MovesListRenderer extends ListRenderer {
|
||||
static rowsTemplate = "stock.AddPackageListRendererRows";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.addDialog = useOwnedDialogs();
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.descriptionColumn = "description_picking";
|
||||
this.productColumns = ["product_id", "product_template_id"];
|
||||
|
||||
onWillStart(async () => {
|
||||
this.hasPackageActive = await user.hasGroup("stock.group_tracking_lot");
|
||||
});
|
||||
}
|
||||
|
||||
async onClickMovePackage() {
|
||||
// If picking doesn't exist yet or location is outdated, it will lead to incorrect results
|
||||
const canOpenDialog = await this.forceSave();
|
||||
if (!canOpenDialog) {
|
||||
return;
|
||||
}
|
||||
const domain = [];
|
||||
if (this.locationId) {
|
||||
domain.push(["location_id", "child_of", this.locationId]);
|
||||
}
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: _t("Select Packages to Move"),
|
||||
noCreate: true,
|
||||
multiSelect: true,
|
||||
resModel: "stock.package",
|
||||
domain,
|
||||
context: {
|
||||
list_view_ref: "stock.stock_package_view_add_list",
|
||||
},
|
||||
onSelected: async (resIds) => {
|
||||
if (resIds.length) {
|
||||
const done = await this.orm.call("stock.picking", "action_add_entire_packs", [
|
||||
[this.pickingId],
|
||||
resIds,
|
||||
]);
|
||||
if (done) {
|
||||
await this.actionService.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "soft_reload",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get canAddPackage() {
|
||||
return (
|
||||
this.hasPackageActive &&
|
||||
!["done", "cancel"].includes(this.props.list.context.picking_state) &&
|
||||
this.props.list.context.picking_type_code !== "incoming"
|
||||
);
|
||||
}
|
||||
|
||||
async forceSave() {
|
||||
// This means the record hasn't been saved once, but we need the picking id to know for which picking we create the move lines.
|
||||
const record = this.env.model.root;
|
||||
const result = await record.save();
|
||||
this.pickingId = record.data.id;
|
||||
this.locationId = record.data.location_id?.id;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
patch(MovesListRenderer.prototype, ProductNameAndDescriptionListRendererMixin);
|
||||
|
||||
export class StockMoveX2ManyField extends X2ManyField {
|
||||
static components = { ...X2ManyField.components, ListRenderer: MovesListRenderer };
|
||||
}
|
||||
|
||||
export const stockMoveX2ManyField = {
|
||||
...x2ManyField,
|
||||
component: StockMoveX2ManyField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("stock_move_one2many", stockMoveX2ManyField);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ProductNameAndDescriptionField } from "@product/product_name_and_description/product_name_and_description";
|
||||
import { many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
export class MoveProductLabelField extends ProductNameAndDescriptionField {
|
||||
static template = "stock.MoveProductLabelField";
|
||||
static descriptionColumn = "description_picking";
|
||||
|
||||
get label() {
|
||||
const record = this.props.record.data;
|
||||
let label = record[this.descriptionColumn];
|
||||
const productName = record.product_id.display_name;
|
||||
if (label === productName) {
|
||||
label = "";
|
||||
}
|
||||
return label.trim();
|
||||
}
|
||||
get isDescriptionReadonly() {
|
||||
return this.props.readonly && ["done", "cancel"].includes(this.props.record.evalContext.parent.state);
|
||||
}
|
||||
get showLabelVisibilityToggler() {
|
||||
return !this.isDescriptionReadonly && this.columnIsProductAndLabel.value && !this.label;
|
||||
}
|
||||
parseLabel(value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const moveProductLabelField = {
|
||||
...many2OneField,
|
||||
component: MoveProductLabelField,
|
||||
};
|
||||
registry.category("fields").add("move_product_label_field", moveProductLabelField);
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="stock.MoveProductLabelField">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<Many2One t-props="this.m2oProps" cssClass="'w-100'" t-on-keydown="onM2oInputKeydown"/>
|
||||
<t t-if="showLabelVisibilityToggler">
|
||||
<button
|
||||
class="btn fa fa-bars text-start o_external_button px-1"
|
||||
type="button"
|
||||
id="labelVisibilityButtonId"
|
||||
data-tooltip="Click or press enter to add a description"
|
||||
t-on-click="() => this.switchLabelVisibility()"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
<textarea
|
||||
class="o_input d-print-none border-0 fst-italic"
|
||||
placeholder="Enter a description"
|
||||
rows="1"
|
||||
type="text"
|
||||
t-att-class="{ 'd-none': !(columnIsProductAndLabel.value and (label or labelVisibility.value)) }"
|
||||
t-att-readonly="isDescriptionReadonly"
|
||||
t-att-value="label"
|
||||
t-ref="labelNodeRef"
|
||||
/>
|
||||
</t>
|
||||
|
||||
<t t-name="stock.AddPackageListRendererRows" t-inherit="web.ListRenderer.Rows" t-inherit-mode="primary">
|
||||
<td class="o_field_x2many_list_row_add" position="inside">
|
||||
<a t-if="canAddPackage" href="#" class="px-3" tabindex="0" t-on-click.stop.prevent="() => this.onClickMovePackage()">Move a Pack</a>
|
||||
</td>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
import { debounce } from "@web/core/utils/timing";
|
||||
|
||||
|
||||
export class StockOrderpointSearchModel extends SearchModel {
|
||||
static DEBOUNCE_DELAY = 500;
|
||||
|
||||
setup(services) {
|
||||
super.setup(services);
|
||||
this.ui = useService("ui");
|
||||
this.applyGlobalHorizonDays = debounce(
|
||||
this.applyGlobalHorizonDays.bind(this),
|
||||
StockOrderpointSearchModel.DEBOUNCE_DELAY
|
||||
);
|
||||
}
|
||||
|
||||
async applyGlobalHorizonDays(globalHorizonDays) {
|
||||
this.ui.block();
|
||||
this.globalContext = {
|
||||
...this.globalContext,
|
||||
global_horizon_days: globalHorizonDays,
|
||||
};
|
||||
this._context = false; // Force rebuild of this.context to take into account the updated this.globalContext
|
||||
await this.orm.call("stock.warehouse.orderpoint", "action_open_orderpoints", [], {
|
||||
context: {
|
||||
...this.context,
|
||||
force_orderpoint_recompute: true,
|
||||
}
|
||||
});
|
||||
await this._fetchSections(this.categories, this.filters);
|
||||
this._notify();
|
||||
this.ui.unblock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { onWillStart, useState } from '@odoo/owl';
|
||||
import { SearchPanel } from "@web/search/search_panel/search_panel";
|
||||
|
||||
|
||||
export class StockOrderpointSearchPanel extends SearchPanel {
|
||||
static template = "stock.StockOrderpointSearchPanel";
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
super.setup(...arguments);
|
||||
this.globalHorizonDays = useState({value: 0});
|
||||
onWillStart(this.getHorizonParameter);
|
||||
}
|
||||
|
||||
async getHorizonParameter() {
|
||||
let res = await this.orm.call("stock.warehouse.orderpoint", "get_horizon_days", [0]);
|
||||
this.globalHorizonDays.value = Math.abs(parseInt(res)) || 0;
|
||||
}
|
||||
|
||||
async applyGlobalHorizonDays(ev) {
|
||||
this.globalHorizonDays.value = Math.max(parseInt(ev.target.value || 0), 0);
|
||||
await this.env.searchModel.applyGlobalHorizonDays(this.globalHorizonDays.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="stock.StockOrderpointSearchPanel.Regular" t-inherit="web.SearchPanel.Regular">
|
||||
<xpath expr="//div[contains(@class, 'o_search_panel')]" position="inside">
|
||||
<section class="o_search_panel_section o_search_panel_filter">
|
||||
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase cursor-default">
|
||||
<i t-attf-class="fa fa-clock-o o_search_panel_section_icon text-warning me-2" />
|
||||
<b>Horizon</b>
|
||||
</header>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="number" min="0" t-att-value="globalHorizonDays.value" class="form-control" aria-describedby="days-label" t-on-change="applyGlobalHorizonDays"/>
|
||||
<span class="input-group-text" id="days-label">days</span>
|
||||
</div>
|
||||
</section>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="stock.StockOrderpointSearchPanel" t-inherit="web.SearchPanel" t-inherit-mode="primary">
|
||||
<xpath expr="//t[@t-call='web.SearchPanel.Regular']" position="attributes">
|
||||
<attribute name="t-call">stock.StockOrderpointSearchPanel.Regular</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
|
||||
export class StockReportSearchModel extends SearchModel {
|
||||
|
||||
async load() {
|
||||
await super.load(...arguments);
|
||||
await this._loadWarehouses();
|
||||
}
|
||||
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
getWarehouses() {
|
||||
return this.warehouses;
|
||||
}
|
||||
|
||||
async _loadWarehouses() {
|
||||
this.warehouses = await this.orm.call(
|
||||
'stock.warehouse',
|
||||
'get_current_warehouses',
|
||||
[[]],
|
||||
{ context: this.context },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the context of a warehouse so values calculate based on all possible
|
||||
* warehouses
|
||||
*/
|
||||
clearWarehouseContext() {
|
||||
delete this.globalContext.warehouse_id;
|
||||
this._notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} warehouse_id
|
||||
* Sets the context to the selected warehouse so values that take this into account
|
||||
* will recalculate based on this without filtering out any records
|
||||
*/
|
||||
applyWarehouseContext(warehouse_id) {
|
||||
this.globalContext['warehouse_id'] = warehouse_id;
|
||||
this._notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SearchPanel } from "@web/search/search_panel/search_panel";
|
||||
|
||||
export class StockReportSearchPanel extends SearchPanel {
|
||||
static template = "stock.StockReportSearchPanel";
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.selectedWarehouse = false;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
get warehouses() {
|
||||
return this.env.searchModel.getWarehouses();
|
||||
}
|
||||
|
||||
clearWarehouseContext() {
|
||||
this.env.searchModel.clearWarehouseContext();
|
||||
this.selectedWarehouse = null;
|
||||
}
|
||||
|
||||
applyWarehouseContext(warehouse_id) {
|
||||
this.env.searchModel.applyWarehouseContext(warehouse_id);
|
||||
this.selectedWarehouse = warehouse_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="stock.StockReportSearchPanel.Regular" t-inherit="web.SearchPanel.Regular">
|
||||
<xpath expr="//div/section[1]" position="before">
|
||||
<section t-if="warehouses.length > 1" class="o_search_panel_section o_search_panel_warehouse">
|
||||
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase o_cursor_default">
|
||||
<i t-attf-class="fa fa-filter o_search_panel_section_icon text-warning me-2"/>
|
||||
<b>Warehouses</b>
|
||||
</header>
|
||||
<ul class="list-group d-block o_search_panel_field">
|
||||
<li class="o_search_panel_filter_value list-group-item p-0 mb-1 border-0 o_cursor_pointer">
|
||||
<span t-on-click="ev => this.clearWarehouseContext(ev)" t-att-class="{'fw-bolder': !selectedWarehouse, 'o_search_panel_label_title':true}">All Warehouses</span>
|
||||
</li>
|
||||
<li class="o_search_panel_filter_value list-group-item p-0 mb-1 border-0 o_cursor_pointer"
|
||||
t-foreach="warehouses" t-as="warehouse" t-key="warehouse.id">
|
||||
<div t-out="warehouse.name"
|
||||
t-on-click="ev => this.applyWarehouseContext(warehouse.id, ev)"
|
||||
t-att-class="{'fw-bolder': selectedWarehouse === warehouse.id, 'o_search_panel_label_title':true,
|
||||
'bg-info-subtle': selectedWarehouse === warehouse.id}"/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="stock.StockReportSearchPanel" t-inherit="web.SearchPanel" t-inherit-mode="primary">
|
||||
<xpath expr="//t[@t-call='web.SearchPanel.Regular']" position="attributes">
|
||||
<attribute name="t-call">stock.StockReportSearchPanel.Regular</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
29
frontend/stock/static/src/views/stock_empty_list_help.js
Normal file
29
frontend/stock/static/src/views/stock_empty_list_help.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useActionLinks } from "@web/views/view_hook";
|
||||
|
||||
export class StockActionHelper extends Component {
|
||||
static template = "stock.StockActionHelper";
|
||||
static props = ["noContentHelp"];
|
||||
setup() {
|
||||
const resModel = "searchModel" in this.env ? this.env.searchModel.resModel : undefined;
|
||||
this.handler = useActionLinks(resModel);
|
||||
}
|
||||
}
|
||||
|
||||
export class StockListRenderer extends ListRenderer {
|
||||
static template = "stock.StockListRenderer";
|
||||
static components = {
|
||||
...StockListRenderer.components,
|
||||
StockActionHelper,
|
||||
};
|
||||
}
|
||||
|
||||
export const StockListView = {
|
||||
...listView,
|
||||
Renderer: StockListRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("stock_list_view", StockListView);
|
||||
20
frontend/stock/static/src/views/stock_empty_list_help.xml
Normal file
20
frontend/stock/static/src/views/stock_empty_list_help.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="stock.StockListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
|
||||
<ActionHelper position="replace">
|
||||
<t t-if="showNoContentHelper">
|
||||
<StockActionHelper noContentHelp="props.noContentHelp"/>
|
||||
</t>
|
||||
</ActionHelper>
|
||||
</t>
|
||||
|
||||
<t t-name="stock.StockActionHelper">
|
||||
<div class="o_view_nocontent">
|
||||
<div t-on-click="handler" class="o_nocontent_help">
|
||||
<t t-out="props.noContentHelp"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ListController } from '@web/views/list/list_controller';
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
export class StockOrderpointListController extends ListController {
|
||||
static template = "stock.StockOrderpoint.listView";
|
||||
|
||||
static components = {
|
||||
...super.components,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
}
|
||||
|
||||
get nbSelected() {
|
||||
return this.model.root.selection.length;
|
||||
}
|
||||
|
||||
async onClickOrder(force_to_max) {
|
||||
const resIds = await this.model.root.getResIds(true);
|
||||
const action = await this.model.orm.call(this.props.resModel, 'action_replenish', [resIds], {
|
||||
context: this.props.context,
|
||||
force_to_max: force_to_max,
|
||||
});
|
||||
if (action) {
|
||||
await this.actionService.doAction(action);
|
||||
}
|
||||
return this.actionService.doAction({type: 'ir.actions.client', tag: 'reload'});
|
||||
}
|
||||
|
||||
async onClickSnooze() {
|
||||
const resIds = await this.model.root.getResIds(true);
|
||||
return this.actionService.doAction('stock.action_orderpoint_snooze', {
|
||||
additionalContext: { default_orderpoint_ids: resIds },
|
||||
onClose: () => { this.actionService.doAction({type: 'ir.actions.client', tag: 'reload'}); },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { listView } from '@web/views/list/list_view';
|
||||
import { registry } from "@web/core/registry";
|
||||
import { StockOrderpointListController as Controller } from './stock_orderpoint_list_controller';
|
||||
import { StockOrderpointSearchPanel } from './search/stock_orderpoint_search_panel';
|
||||
import { StockOrderpointSearchModel } from './search/stock_orderpoint_search_model';
|
||||
|
||||
export const StockOrderpointListView = {
|
||||
...listView,
|
||||
Controller,
|
||||
SearchPanel: StockOrderpointSearchPanel,
|
||||
SearchModel: StockOrderpointSearchModel,
|
||||
};
|
||||
|
||||
registry.category("views").add("stock_orderpoint_list", StockOrderpointListView);
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="stock.StockOrderpoint.listView" t-inherit="web.ListView">
|
||||
<xpath expr="//SelectionBox" position="after">
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary">
|
||||
<span class="o_dropdown_title">Order</span>
|
||||
<i class="fa fa-caret-down ms-1"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem t-if="hasSelectedRecords" onSelected="() => this.onClickOrder(false)">
|
||||
Order
|
||||
</DropdownItem>
|
||||
<DropdownItem t-if="hasSelectedRecords" onSelected="() => this.onClickOrder(true)">
|
||||
Order To Max
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<button t-if="hasSelectedRecords" type="button" t-on-click="onClickSnooze"
|
||||
class="o_button_snooze btn btn-secondary me-1">
|
||||
Snooze
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
72
frontend/stock/static/src/widgets/counted_quantity_widget.js
Normal file
72
frontend/stock/static/src/widgets/counted_quantity_widget.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { FloatField, floatField } from "@web/views/fields/float/float_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
import { useEffect, useRef } from "@odoo/owl";
|
||||
|
||||
export class CountedQuantityWidgetField extends FloatField {
|
||||
setup() {
|
||||
// Need to adapt useInputField to overide onInput and onChange
|
||||
super.setup();
|
||||
|
||||
const inputRef = useRef("numpadDecimal");
|
||||
|
||||
useEffect(
|
||||
(inputEl) => {
|
||||
if (inputEl) {
|
||||
const boundOnInput = this.onInput.bind(this);
|
||||
const boundOnKeydown = this.onKeydown.bind(this);
|
||||
const boundOnBlur = this.onBlur.bind(this);
|
||||
inputEl.addEventListener("input", boundOnInput);
|
||||
inputEl.addEventListener("keydown", boundOnKeydown);
|
||||
inputEl.addEventListener("blur", boundOnBlur);
|
||||
return () => {
|
||||
inputEl.removeEventListener("input", boundOnInput);
|
||||
inputEl.removeEventListener("keydown", boundOnKeydown);
|
||||
inputEl.removeEventListener("blur", boundOnBlur);
|
||||
};
|
||||
}
|
||||
},
|
||||
() => [inputRef.el]
|
||||
);
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
//TODO remove in master
|
||||
}
|
||||
|
||||
updateValue(ev){
|
||||
try {
|
||||
const val = this.parse(ev.target.value);
|
||||
this.props.record.update({ [this.props.name]: val, inventory_quantity_set: true });
|
||||
} catch {} // ignore since it will be handled later
|
||||
}
|
||||
|
||||
onBlur(ev) {
|
||||
this.updateValue(ev);
|
||||
}
|
||||
|
||||
onKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (["enter", "tab", "shift+tab"].includes(hotkey)) {
|
||||
this.updateValue(ev);
|
||||
this.onInput(ev);
|
||||
}
|
||||
}
|
||||
|
||||
get formattedValue() {
|
||||
if (
|
||||
this.props.readonly &&
|
||||
!this.props.record.data[this.props.name] & !this.props.record.data.inventory_quantity_set
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return super.formattedValue;
|
||||
}
|
||||
}
|
||||
|
||||
export const countedQuantityWidgetField = {
|
||||
...floatField,
|
||||
component: CountedQuantityWidgetField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("counted_quantity_widget", countedQuantityWidgetField);
|
||||
28
frontend/stock/static/src/widgets/forced_placeholder.js
Normal file
28
frontend/stock/static/src/widgets/forced_placeholder.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
export class ForcedPlaceholder extends Many2One {
|
||||
static template = "stock.ForcedPlaceholder";
|
||||
static components = { ...Many2One.components };
|
||||
static props = { ...Many2One.props };
|
||||
}
|
||||
|
||||
export class ForcedPlaceholderField extends Component {
|
||||
static template = "stock.ForcedPlaceholderField";
|
||||
static components = { ForcedPlaceholder };
|
||||
static props = { ...Many2OneField.props };
|
||||
|
||||
get m2oProps() {
|
||||
const props = computeM2OProps(this.props);
|
||||
return {
|
||||
...props,
|
||||
canOpen: !props.readonly && props.canOpen, // to remove the wrong link and the hand cursor on hover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("stock.forced_placeholder", {
|
||||
...buildM2OFieldDescription(ForcedPlaceholderField),
|
||||
});
|
||||
18
frontend/stock/static/src/widgets/forced_placeholder.xml
Normal file
18
frontend/stock/static/src/widgets/forced_placeholder.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--This field displays a placeholder even if the field is currently not selected.-->
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="stock.ForcedPlaceholderField">
|
||||
<ForcedPlaceholder t-props="m2oProps"/>
|
||||
</t>
|
||||
|
||||
<t t-name="stock.ForcedPlaceholder" t-inherit="web.Many2One">
|
||||
<xpath expr="//t[@t-if='props.value']" position="after">
|
||||
<t t-else="">
|
||||
<span t-esc="props.placeholder"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
66
frontend/stock/static/src/widgets/forecast_widget.js
Normal file
66
frontend/stock/static/src/widgets/forecast_widget.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FloatField, floatField } from "@web/views/fields/float/float_field";
|
||||
import { formatDate } from "@web/core/l10n/dates";
|
||||
import { formatFloat } from "@web/views/fields/formatters";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ForecastWidgetField extends FloatField {
|
||||
static template = "stock.ForecastWidget";
|
||||
setup() {
|
||||
const { data, fields, resId } = this.props.record;
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.resId = resId;
|
||||
|
||||
this.forecastExpectedDate = formatDate(
|
||||
data.forecast_expected_date,
|
||||
fields.forecast_expected_date
|
||||
);
|
||||
if (data.forecast_expected_date && data.date_deadline) {
|
||||
this.forecastIsLate = data.forecast_expected_date > data.date_deadline;
|
||||
}
|
||||
const digits = fields.forecast_availability.digits;
|
||||
const options = { digits, thousandsSep: "", decimalPoint: "." };
|
||||
const forecast_availability = parseFloat(formatFloat(data.forecast_availability, options));
|
||||
const product_qty = parseFloat(formatFloat(data.product_qty, options));
|
||||
this.willBeFulfilled = forecast_availability >= product_qty;
|
||||
this.state = data.state;
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens the Forecast Report for the `stock.move` product.
|
||||
*/
|
||||
async _openReport(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (!this.resId || !this.props.record.data.is_storable) {
|
||||
return;
|
||||
}
|
||||
const action = await this.orm.call("stock.move", "action_product_forecast_report", [
|
||||
this.resId,
|
||||
]);
|
||||
this.actionService.doAction(action);
|
||||
}
|
||||
|
||||
get decoration() {
|
||||
if (!this.forecastExpectedDate && this.willBeFulfilled){
|
||||
return "text-bg-success"
|
||||
} else if (this.forecastExpectedDate && this.willBeFulfilled){
|
||||
return this.forecastIsLate ? 'text-bg-danger' : 'text-bg-warning'
|
||||
} else {
|
||||
return 'text-bg-danger'
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export const forecastWidgetField = {
|
||||
...floatField,
|
||||
component: ForecastWidgetField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("forecast_widget", forecastWidgetField);
|
||||
19
frontend/stock/static/src/widgets/forecast_widget.xml
Normal file
19
frontend/stock/static/src/widgets/forecast_widget.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="stock.ForecastWidget">
|
||||
<span title="Forecasted Report"
|
||||
t-on-click="_openReport" t-att="resId ? {} : {'disabled': ''}"
|
||||
class="badge rounded-pill align-middle"
|
||||
t-att-class="decoration"
|
||||
>
|
||||
<t t-if="!forecastExpectedDate and willBeFulfilled">
|
||||
Available
|
||||
</t>
|
||||
<t t-elif="forecastExpectedDate and willBeFulfilled">
|
||||
Exp <t t-out="forecastExpectedDate"/>
|
||||
</t>
|
||||
<t t-else="">Not Available</t>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
142
frontend/stock/static/src/widgets/generate_serial.js
Normal file
142
frontend/stock/static/src/widgets/generate_serial.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
import { Dialog } from '@web/core/dialog/dialog';
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { parseInteger } from "@web/views/fields/parsers";
|
||||
import { getId } from "@web/model/relational_model/utils";
|
||||
import { Component, useRef, onMounted, onWillStart } from "@odoo/owl";
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
export class GenerateDialog extends Component {
|
||||
static template = "stock.generate_serial_dialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
mode: { type: String },
|
||||
move: { type: Object },
|
||||
close: { type: Function },
|
||||
};
|
||||
setup() {
|
||||
this.size = 'md';
|
||||
if (this.props.mode === 'generate') {
|
||||
this.title = this.props.move.data.has_tracking === 'lot'
|
||||
? _t("Generate Lot numbers")
|
||||
: _t("Generate Serial numbers");
|
||||
} else {
|
||||
this.title = this.props.move.data.has_tracking === 'lot' ? _t("Import Lots") : _t("Import Serials");
|
||||
}
|
||||
|
||||
this.nextSerial = useRef('nextSerial');
|
||||
this.nextSerialCount = useRef('nextSerialCount');
|
||||
this.totalReceived = useRef('totalReceived');
|
||||
this.keepLines = useRef('keepLines');
|
||||
this.lots = useRef('lots');
|
||||
this.orm = useService("orm");
|
||||
onWillStart(async () => {
|
||||
this.displayUOM = await user.hasGroup("uom.group_uom");
|
||||
});
|
||||
onMounted(() => {
|
||||
if (this.props.mode === 'generate') {
|
||||
this.nextSerialCount.el.value = this.props.move.data.product_uom_qty || 2;
|
||||
if (this.props.move.data.has_tracking === 'lot') {
|
||||
this.totalReceived.el.value = this.props.move.data.quantity;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async _onGenerateCustomSerial() {
|
||||
const product = (await this.orm.searchRead("product.product", [["id", "=", this.props.move.data.product_id.id]], ["lot_sequence_id"]))[0];
|
||||
this.sequence = product.lot_sequence_id;
|
||||
if (product.lot_sequence_id) {
|
||||
this.sequence = (await this.orm.searchRead("ir.sequence", [["id", "=", this.sequence[0]]], ["number_next_actual"]))[0];
|
||||
this.nextCustomSerialNumber = await this.orm.call("ir.sequence", "next_by_id", [this.sequence.id]);
|
||||
this.nextSerial.el.value = this.nextCustomSerialNumber;
|
||||
}
|
||||
}
|
||||
async _onGenerate() {
|
||||
let count;
|
||||
let qtyToProcess;
|
||||
if (this.props.move.data.has_tracking === 'lot'){
|
||||
count = parseFloat(this.nextSerialCount.el?.value || '0');
|
||||
qtyToProcess = parseFloat(this.totalReceived.el?.value || this.props.move.data.product_qty);
|
||||
} else {
|
||||
count = parseInteger(this.nextSerialCount.el?.value || '0');
|
||||
qtyToProcess = this.props.move.data.product_qty;
|
||||
}
|
||||
const move_line_vals = await this.orm.call("stock.move", "action_generate_lot_line_vals", [{
|
||||
...this.props.move.context,
|
||||
default_product_id: this.props.move.data.product_id.id,
|
||||
default_location_dest_id: this.props.move.data.location_dest_id.id,
|
||||
default_location_id: this.props.move.data.location_id.id,
|
||||
default_tracking: this.props.move.data.has_tracking,
|
||||
default_quantity: qtyToProcess,
|
||||
},
|
||||
this.props.mode,
|
||||
this.nextSerial.el?.value,
|
||||
count,
|
||||
this.lots.el?.value,
|
||||
]);
|
||||
const newlines = [];
|
||||
let lines = []
|
||||
lines = this.props.move.data.move_line_ids;
|
||||
|
||||
// create records directly from values to bypass onchanges
|
||||
for (const values of move_line_vals) {
|
||||
newlines.push(
|
||||
lines._createRecordDatapoint(values, {
|
||||
mode: 'readonly',
|
||||
virtualId: getId("virtual"),
|
||||
manuallyAdded: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!this.keepLines.el.checked) {
|
||||
await lines._applyCommands(lines._currentIds.map((currentId) => [
|
||||
x2ManyCommands.DELETE,
|
||||
currentId,
|
||||
]));
|
||||
}
|
||||
lines.records.push(...newlines);
|
||||
lines._commands.push(...newlines.map((record) => [
|
||||
x2ManyCommands.CREATE,
|
||||
record._virtualId,
|
||||
]));
|
||||
lines._currentIds.push(...newlines.map((record) => record._virtualId));
|
||||
await lines._onUpdate();
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
|
||||
class GenerateSerials extends Component {
|
||||
static template = "stock.GenerateSerials";
|
||||
static props = {...standardWidgetProps};
|
||||
|
||||
setup(){
|
||||
this.dialog = useService("dialog");
|
||||
}
|
||||
|
||||
openDialog(ev){
|
||||
this.dialog.add(GenerateDialog, {
|
||||
move: this.props.record,
|
||||
mode: 'generate',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ImportLots extends Component {
|
||||
static template = "stock.ImportLots";
|
||||
static props = {...standardWidgetProps};
|
||||
setup(){
|
||||
this.dialog = useService("dialog");
|
||||
}
|
||||
|
||||
openDialog(ev){
|
||||
this.dialog.add(GenerateDialog, {
|
||||
move: this.props.record,
|
||||
mode: 'import',
|
||||
});
|
||||
}
|
||||
}
|
||||
registry.category("view_widgets").add("import_lots", {component: ImportLots});
|
||||
registry.category("view_widgets").add("generate_serials", {component: GenerateSerials});
|
||||
166
frontend/stock/static/src/widgets/json_widget.js
Normal file
166
frontend/stock/static/src/widgets/json_widget.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { getColor } from "@web/core/colors/colors";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { user } from "@web/core/user";
|
||||
import { Component, onWillStart, useEffect, useRef } from "@odoo/owl";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
export class JsonPopOver extends Component {
|
||||
static template = "";
|
||||
static props = {...standardFieldProps};
|
||||
get jsonValue() {
|
||||
return JSON.parse(this.props.record.data[this.props.name]);
|
||||
}
|
||||
}
|
||||
|
||||
export const jsonPopOver = {
|
||||
component: JsonPopOver,
|
||||
displayName: _t("Json Popup"),
|
||||
supportedTypes: ["char"],
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Lead Days
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export class PopOverLeadDays extends JsonPopOver {
|
||||
static template = "stock.leadDays";
|
||||
}
|
||||
|
||||
export const popOverLeadDays = {
|
||||
...jsonPopOver,
|
||||
component: PopOverLeadDays,
|
||||
};
|
||||
registry.category("fields").add("lead_days_widget", popOverLeadDays);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Forecast Graph
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export class ReplenishmentGraphWidget extends JsonPopOver {
|
||||
static template = "stock.replenishmentGraph";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.chart = null;
|
||||
this.canvasRef = useRef("canvas");
|
||||
onWillStart(async () => {
|
||||
this.displayUOM = await user.hasGroup("uom.group_uom");
|
||||
await loadBundle("web.chartjs_lib");
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
this.renderChart();
|
||||
return () => {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
get productUomName(){
|
||||
return this.jsonValue["product_uom_name"];
|
||||
}
|
||||
get qtyOnHand(){
|
||||
return this.jsonValue["qty_on_hand"];
|
||||
}
|
||||
get productMaxQty() {
|
||||
return this.jsonValue["product_max_qty"];
|
||||
}
|
||||
get productMinQty() {
|
||||
return this.jsonValue["product_min_qty"];
|
||||
}
|
||||
get dailyDemand() {
|
||||
return this.jsonValue["daily_demand"];
|
||||
}
|
||||
get averageStock() {
|
||||
return this.jsonValue["average_stock"];
|
||||
}
|
||||
get orderingPeriod() {
|
||||
return this.jsonValue["ordering_period"];
|
||||
}
|
||||
get qtiesAreTheSame() {
|
||||
return this.productMinQty === this.productMaxQty;
|
||||
}
|
||||
get leadTime() {
|
||||
return this.jsonValue["lead_time"];
|
||||
}
|
||||
|
||||
renderChart() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
const config = this.getScatterGraphConfig();
|
||||
this.chart = new Chart(this.canvasRef.el, config);
|
||||
}
|
||||
|
||||
getScatterGraphConfig() {
|
||||
const dashLine = (ctx, value) => ctx.p1.raw.x === this.jsonValue['x_axis_vals'].slice(-1)[0] ? value : undefined;
|
||||
const pushYLabels = (ticks) => ticks.push({value: this.productMinQty}, {value: this.productMaxQty});
|
||||
const showYLabel = (tick) => tick === this.productMinQty || tick === this.productMaxQty ? tick : '';
|
||||
const labels = this.jsonValue['x_axis_vals'];
|
||||
const maxLineColor = getColor(1, cookie.get("color_scheme"), "odoo");
|
||||
const minLineColor = getColor(2, cookie.get("color_scheme"), "odoo");
|
||||
const curveLineColor = getColor(3, cookie.get("color_scheme"), "odoo");
|
||||
return {
|
||||
type: "scatter",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
type: "line",
|
||||
data: this.jsonValue["max_line_vals"],
|
||||
fill: false,
|
||||
pointStyle: false,
|
||||
borderColor: maxLineColor,
|
||||
}, {
|
||||
type: "line",
|
||||
data: this.jsonValue["min_line_vals"],
|
||||
fill: false,
|
||||
pointStyle: false,
|
||||
borderColor: minLineColor,
|
||||
}, {
|
||||
type: "line",
|
||||
data: this.jsonValue["curve_line_vals"],
|
||||
fill: false,
|
||||
pointStyle: false,
|
||||
borderColor: curveLineColor,
|
||||
segment: {
|
||||
borderDash: ctx => dashLine(ctx, [6, 6]),
|
||||
}
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
showLine: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
grid: {display: false},
|
||||
beforeTickToLabelConversion: data => pushYLabels(data.ticks),
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
callback: tick => showYLabel(tick),
|
||||
},
|
||||
suggestedMax: this.productMaxQty * 1.1,
|
||||
suggestedMin: this.productMinQty * 0.9,
|
||||
},
|
||||
x: {
|
||||
type: 'category',
|
||||
grid: {display: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const replenishmentGraphWidget = {
|
||||
...jsonPopOver,
|
||||
component: ReplenishmentGraphWidget,
|
||||
};
|
||||
|
||||
registry.category("fields").add("replenishment_graph_widget", replenishmentGraphWidget);
|
||||
43
frontend/stock/static/src/widgets/json_widget.xml
Normal file
43
frontend/stock/static/src/widgets/json_widget.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<div t-name="stock.leadDays">
|
||||
<h2>Forecasted Date</h2>
|
||||
<hr/>
|
||||
<table t-if="jsonValue.lead_days_description" class="table table-borderless table-sm">
|
||||
<tbody>
|
||||
<tr class="table-secondary">
|
||||
<td>Today</td>
|
||||
<td class="text-end" t-out="jsonValue.today"/>
|
||||
</tr>
|
||||
<tr t-foreach="jsonValue.lead_days_description" t-key="descr_index" t-as="descr"
|
||||
t-attf-class="{{ descr[2] ? 'table-secondary' : '' }}">
|
||||
<td t-out="descr[0]"/>
|
||||
<td class="text-end" t-out="descr[1]"/>
|
||||
</tr>
|
||||
<tr class="table-info">
|
||||
<td>Forecasted Date</td>
|
||||
<td class="text-end text-nowrap">
|
||||
= <t t-out="jsonValue.lead_horizon_date"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div t-name="stock.replenishmentGraph" class="col-11">
|
||||
<div class="o_replenishment_graph">
|
||||
<canvas t-ref="canvas"/>
|
||||
</div>
|
||||
<div class="d-flex pt-3">
|
||||
<h6 class="row text-muted">
|
||||
<span>Daily Demand: <span t-out="dailyDemand"/> <span t-out="productUomName"/>/day</span>
|
||||
<span class="pt-2">Average Stock: <span t-out="averageStock"/> <span t-out="productUomName"/></span>
|
||||
</h6>
|
||||
<h6 class="row text-muted">
|
||||
<span t-if="!qtiesAreTheSame">Ordering Frequency: <span t-out="orderingPeriod"/> day(s)</span>
|
||||
<span t-if="qtiesAreTheSame">Ordering Frequency: On demand</span>
|
||||
<span class="pt-2">Lead Time: <span t-out="leadTime"/> day(s)</span>
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
106
frontend/stock/static/src/widgets/lots_dialog.xml
Normal file
106
frontend/stock/static/src/widgets/lots_dialog.xml
Normal file
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="stock.GenerateSerials">
|
||||
<button class="btn btn-link" t-on-click="openDialog">Generate Serials/Lots</button>
|
||||
</t>
|
||||
<t t-name="stock.ImportLots">
|
||||
<button class="btn btn-link" t-on-click="openDialog">Import Serials/Lots</button>
|
||||
</t>
|
||||
<t t-name="stock.generate_serial_dialog">
|
||||
<Dialog size="size" title="title" withBodyPadding="false">
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary" t-on-click="_onGenerate">Generate</button>
|
||||
<button class="btn btn-secondary" t-on-click="() => this.props.close()">
|
||||
Discard
|
||||
</button>
|
||||
</t>
|
||||
<div class="o_form_view o_form_nosheet">
|
||||
<t t-if="props.mode === 'generate'">
|
||||
<div class="container">
|
||||
<div class="row mb-2">
|
||||
<div class="col-3">
|
||||
<label class="o_form_label" for="next_serial_0">
|
||||
<t t-if="props.move.data.has_tracking === 'lot'">First Lot Number</t>
|
||||
<t t-else="">First Serial Number</t>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div name="next_serial" class="o_field_widget o_field_char">
|
||||
<input placeholder="e.g. LOT-PR-00012" class="o_input" t-ref="nextSerial" id="next_serial_0" type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-primary py-1" t-on-click="_onGenerateCustomSerial">New</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<label class="o_form_label" for="next_serial_count_0">
|
||||
<t t-if="props.move.data.has_tracking === 'lot'">Quantity per Lot</t>
|
||||
<t t-else="">Number of SN</t>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<div name="next_serial_count" class="o_field_widget o_field_integer">
|
||||
<input inputmode="numeric" t-ref="nextSerialCount" class="o_input" id="next_serial_count_0" type="number"
|
||||
t-on-keydown="(ev) => props.move.data.has_tracking != 'lot' and ev.key === '.' and ev.preventDefault()"/>
|
||||
</div>
|
||||
<span t-if="props.move.data.has_tracking === 'lot' && displayUOM" t-esc="props.move.data.product_uom.display_name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="props.move.data.has_tracking === 'lot'" class="row mb-2">
|
||||
<div class="col">
|
||||
<label class="o_form_label" for="total_received_0">Quantity Received</label>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<div name="total_received" class="o_field_widget o_field_integer">
|
||||
<input inputmode="numeric" t-ref="totalReceived" class="o_input" id="total_received_0" type="number"/>
|
||||
</div>
|
||||
<span t-if="displayUOM" t-esc="props.move.data.product_uom.display_name"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label class="o_form_label" for="keep_lines_0">Keep current lines</label>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<div name="keep_lines">
|
||||
<input type="checkbox" t-ref="keepLines" id="keep_lines_0"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="props.mode === 'import'" class="d-flex">
|
||||
<div class="grid o_inner_group">
|
||||
<div class="d-flex">
|
||||
<div class="o_cell flex-grow-0 flex-sm-grow-0 text-900 pe-3">
|
||||
<label class="o_form_label" for="next_serial_0">
|
||||
<t t-if="props.move.data.has_tracking==='lot'">Lot numbers</t>
|
||||
<t t-else="">Serial numbers</t>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_cell flex-grow-1 flex-sm-grow-0">
|
||||
<div name="next_serial" class="o_field_widget o_field_char">
|
||||
<textarea
|
||||
placeholder="Write one lot/serial name per line, followed by the quantity."
|
||||
class="o_input" t-ref="lots" id="next_serial_0" type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="o_cell flex-grow-0 flex-sm-grow-0 text-900 pe-3">
|
||||
<label class="o_form_label" for="keep_lines_0">Keep current lines</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_cell flex-grow-1 flex-sm-grow-0">
|
||||
<div name="keep_lines">
|
||||
<input type="checkbox" t-ref="keepLines" id="keep_lines_0"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
30
frontend/stock/static/src/widgets/many2many_barcode_tags.js
Normal file
30
frontend/stock/static/src/widgets/many2many_barcode_tags.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
|
||||
import {
|
||||
Many2ManyTagsField,
|
||||
many2ManyTagsField,
|
||||
} from "@web/views/fields/many2many_tags/many2many_tags_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class Many2XBarcodeTagsAutocomplete extends Many2XAutocomplete {
|
||||
onQuickCreateError(error, request) {
|
||||
if (error.data?.debug && error.data.debug.includes("psycopg2.errors.UniqueViolation")) {
|
||||
throw error;
|
||||
}
|
||||
super.onQuickCreateError(error, request);
|
||||
}
|
||||
}
|
||||
|
||||
export class Many2ManyBarcodeTagsField extends Many2ManyTagsField {
|
||||
static components = {
|
||||
...Many2ManyTagsField.components,
|
||||
Many2XAutocomplete: Many2XBarcodeTagsAutocomplete,
|
||||
};
|
||||
}
|
||||
|
||||
export const many2ManyBarcodeTagsField = {
|
||||
...many2ManyTagsField,
|
||||
component: Many2ManyBarcodeTagsField,
|
||||
additionalClasses: ['o_field_many2many_tags'],
|
||||
}
|
||||
|
||||
registry.category("fields").add("many2many_barcode_tags", many2ManyBarcodeTagsField);
|
||||
51
frontend/stock/static/src/widgets/popover_widget.js
Normal file
51
frontend/stock/static/src/widgets/popover_widget.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { Component } from "@odoo/owl";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
/**
|
||||
* Extend this to add functionality to Popover (custom methods etc.)
|
||||
* need to extend PopoverWidgetField as well and set its Popover Component to new extension
|
||||
*/
|
||||
export class PopoverComponent extends Component {
|
||||
static template = "stock.popoverContent";
|
||||
static props = ["record", "*"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget Popover for JSON field (char), renders a popover above an icon button on click
|
||||
* {
|
||||
* 'msg': '<CONTENT OF THE POPOVER>' required if not 'popoverTemplate' is given,
|
||||
* 'icon': '<FONT AWESOME CLASS>' default='fa-info-circle',
|
||||
* 'color': '<COLOR CLASS OF ICON>' default='text-primary',
|
||||
* 'position': <POSITION OF THE POPOVER> default='top',
|
||||
* 'popoverTemplate': '<TEMPLATE OF THE POPOVER>' default='stock.popoverContent'
|
||||
* pass a template for popover to use, other data passed in JSON field will be passed
|
||||
* to popover template inside props (ex. props.someValue), must be owl template
|
||||
* }
|
||||
*/
|
||||
|
||||
export class PopoverWidgetField extends Component {
|
||||
static template = "stock.popoverButton";
|
||||
static components = { Popover: PopoverComponent };
|
||||
static props = {...standardFieldProps};
|
||||
setup(){
|
||||
let fieldValue = this.props.record.data[this.props.name];
|
||||
this.jsonValue = JSON.parse(fieldValue || "{}");
|
||||
const position = this.jsonValue.position || "top";
|
||||
this.popover = usePopover(this.constructor.components.Popover, { position });
|
||||
this.color = this.jsonValue.color || 'text-primary';
|
||||
this.icon = this.jsonValue.icon || 'fa-info-circle';
|
||||
}
|
||||
|
||||
showPopup(ev){
|
||||
this.popover.open(ev.currentTarget, { ...this.jsonValue, record: this.props.record });
|
||||
}
|
||||
}
|
||||
|
||||
export const popoverWidgetField = {
|
||||
component: PopoverWidgetField,
|
||||
supportedTypes: ['char'],
|
||||
};
|
||||
|
||||
registry.category("fields").add("popover_widget", popoverWidgetField);
|
||||
13
frontend/stock/static/src/widgets/popover_widget.xml
Normal file
13
frontend/stock/static/src/widgets/popover_widget.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="stock.popoverButton">
|
||||
<a tabindex="0" t-on-click.stop="showPopup" t-attf-class="p-1 fa #{ icon || 'fa-info-circle'} #{ color || 'text-primary'}"/>
|
||||
</t>
|
||||
|
||||
<div t-name="stock.popoverContent" class="m-3">
|
||||
<h6 t-out="props.title"/>
|
||||
<t t-if="props.popoverTemplate" t-call="{{props.popoverTemplate}}" />
|
||||
<t t-else="" t-out="props.msg"/>
|
||||
</div>
|
||||
</templates>
|
||||
43
frontend/stock/static/src/widgets/stock_package_m2m.js
Normal file
43
frontend/stock/static/src/widgets/stock_package_m2m.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Many2ManyTagsField,
|
||||
many2ManyTagsField,
|
||||
} from "@web/views/fields/many2many_tags/many2many_tags_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
|
||||
export class Many2ManyPackageTagsField extends Many2ManyTagsField {
|
||||
setup() {
|
||||
this.hasNoneTag = this.props.record.data?.has_lines_without_result_package || false;
|
||||
}
|
||||
|
||||
get tags() {
|
||||
const tags = super.tags;
|
||||
if (this.hasNoneTag) {
|
||||
tags.push({
|
||||
...this.getTagProps(this.props.record.data[this.props.name].records.at(-1)),
|
||||
id: "datapoint_None",
|
||||
text: _t("No Package"),
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
getTagProps(record) {
|
||||
return {
|
||||
...super.getTagProps(record),
|
||||
text: record.data.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const many2ManyPackageTagsField = {
|
||||
...many2ManyTagsField,
|
||||
component: Many2ManyPackageTagsField,
|
||||
additionalClasses: ['o_field_many2many_tags'],
|
||||
relatedFields: () => [
|
||||
{ name: "name", type: "char" },
|
||||
],
|
||||
}
|
||||
|
||||
registry.category("fields").add("package_m2m", many2ManyPackageTagsField);
|
||||
98
frontend/stock/static/src/widgets/stock_package_m2o.js
Normal file
98
frontend/stock/static/src/widgets/stock_package_m2o.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||
import {
|
||||
buildM2OFieldDescription,
|
||||
extractM2OFieldProps,
|
||||
Many2OneField,
|
||||
} from "@web/views/fields/many2one/many2one_field";
|
||||
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
|
||||
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
|
||||
|
||||
class PackageFormDialog extends FormViewDialog {}
|
||||
|
||||
class Many2XStockPackageAutocomplete extends Many2XAutocomplete {
|
||||
get createDialog() {
|
||||
const packageFormDialog = PackageFormDialog;
|
||||
packageFormDialog.defaultProps = {
|
||||
...packageFormDialog.defaultProps,
|
||||
onRecordSave: async (record) => {
|
||||
// We need to reload to get the name computed from the backend.
|
||||
const saved = await record.save({ reload: true });
|
||||
if (saved && this.props.update) {
|
||||
// Without this, the package is named 'Unnamed' in the UI until the record is saved.
|
||||
this.props.update([{ ...record.data, id: record.resId }]);
|
||||
}
|
||||
return saved;
|
||||
},
|
||||
};
|
||||
return packageFormDialog;
|
||||
}
|
||||
}
|
||||
|
||||
class StockPackageMany2OneReplacer extends Many2One {
|
||||
static components = {
|
||||
...Many2One.components,
|
||||
Many2XAutocomplete: Many2XStockPackageAutocomplete,
|
||||
};
|
||||
}
|
||||
|
||||
export class StockPackageMany2One extends Component {
|
||||
static template = "stock.StockPackageMany2One";
|
||||
static components = { Many2One: StockPackageMany2OneReplacer };
|
||||
static props = {
|
||||
...Many2OneField.props,
|
||||
displaySource: { type: Boolean },
|
||||
displayDestination: { type: Boolean },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.isDone = ["done", "cancel"].includes(this.props.record?.data?.state);
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
const props = computeM2OProps(this.props);
|
||||
return {
|
||||
...props,
|
||||
context: {
|
||||
...props.context,
|
||||
...this.displayNameContext,
|
||||
},
|
||||
value: this.displayValue,
|
||||
};
|
||||
}
|
||||
|
||||
get isEditing() {
|
||||
return this.props.record.isInEdition;
|
||||
}
|
||||
|
||||
get displayValue() {
|
||||
const displayVal = this.props.record.data[this.props.name];
|
||||
if (this.isDone && displayVal?.display_name) {
|
||||
displayVal["display_name"] = displayVal["display_name"].split(" > ").pop();
|
||||
}
|
||||
return displayVal;
|
||||
}
|
||||
|
||||
get displayNameContext() {
|
||||
return {
|
||||
show_src_package: this.props.displaySource,
|
||||
show_dest_package: this.props.displayDestination,
|
||||
is_done: this.isDone,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("package_m2o", {
|
||||
...buildM2OFieldDescription(StockPackageMany2One),
|
||||
extractProps(staticInfo, dynamicInfo) {
|
||||
const context = dynamicInfo.context;
|
||||
return {
|
||||
...extractM2OFieldProps(staticInfo, dynamicInfo),
|
||||
displaySource: !!context?.show_src_package,
|
||||
displayDestination: !!context?.show_dest_package,
|
||||
};
|
||||
},
|
||||
});
|
||||
9
frontend/stock/static/src/widgets/stock_package_m2o.xml
Normal file
9
frontend/stock/static/src/widgets/stock_package_m2o.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="stock.StockPackageMany2One">
|
||||
<t t-if="!isEditing" t-out="displayValue.display_name"/>
|
||||
<Many2One t-else="" t-props="m2oProps"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
53
frontend/stock/static/src/widgets/stock_pick_from.js
Normal file
53
frontend/stock/static/src/widgets/stock_pick_from.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
export class StockPickFrom extends Component {
|
||||
static template = "stock.StockPickFrom";
|
||||
static components = { Many2One };
|
||||
static props = { ...Many2OneField.props };
|
||||
|
||||
get m2oProps() {
|
||||
const props = computeM2OProps(this.props);
|
||||
return {
|
||||
...props,
|
||||
value: props.value || { id: 0, display_name: this._quant_display_name() },
|
||||
};
|
||||
}
|
||||
|
||||
_quant_display_name() {
|
||||
let name_parts = [];
|
||||
// if location group is activated
|
||||
const data = this.props.record.data;
|
||||
name_parts.push(data.location_id?.display_name)
|
||||
if (data.lot_id) {
|
||||
name_parts.push(data.lot_id?.display_name || data.lot_name)
|
||||
}
|
||||
if (data.package_id) {
|
||||
let packageName = data.package_id?.display_name;
|
||||
if (packageName && ["done", "cancel"].includes(data.state)) {
|
||||
packageName = packageName.split(" > ").pop();
|
||||
}
|
||||
name_parts.push(packageName);
|
||||
}
|
||||
if (data.owner) {
|
||||
name_parts.push(data.owner?.display_name)
|
||||
}
|
||||
const result = name_parts.join(" - ");
|
||||
if (result) return result;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("pick_from", {
|
||||
...buildM2OFieldDescription(StockPickFrom),
|
||||
fieldDependencies: [
|
||||
// dependencies to build the quant display name
|
||||
{ name: "location_id", type: "relation" },
|
||||
{ name: "location_dest_id", type: "relation" },
|
||||
{ name: "package_id", type: "relation" },
|
||||
{ name: "owner_id", type: "relation" },
|
||||
{ name: "state", type: "char" },
|
||||
],
|
||||
});
|
||||
8
frontend/stock/static/src/widgets/stock_pick_from.xml
Normal file
8
frontend/stock/static/src/widgets/stock_pick_from.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="stock.StockPickFrom">
|
||||
<Many2One t-props="m2oProps"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
PopoverComponent,
|
||||
PopoverWidgetField,
|
||||
popoverWidgetField,
|
||||
} from "@stock/widgets/popover_widget";
|
||||
|
||||
export class StockRescheculingPopoverComponent extends PopoverComponent {
|
||||
setup(){
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
openElement(ev){
|
||||
this.action.doAction({
|
||||
res_model: ev.currentTarget.getAttribute('element-model'),
|
||||
res_id: parseInt(ev.currentTarget.getAttribute('element-id')),
|
||||
views: [[false, "form"]],
|
||||
type: "ir.actions.act_window",
|
||||
view_mode: "form",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class StockRescheculingPopover extends PopoverWidgetField {
|
||||
static components = {
|
||||
Popover: StockRescheculingPopoverComponent
|
||||
};
|
||||
setup(){
|
||||
super.setup();
|
||||
this.color = this.jsonValue.color || 'text-danger';
|
||||
this.icon = this.jsonValue.icon || 'fa-exclamation-triangle';
|
||||
}
|
||||
|
||||
showPopup(ev){
|
||||
if (!this.jsonValue.late_elements){
|
||||
return;
|
||||
}
|
||||
super.showPopup(ev);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("stock_rescheduling_popover", {
|
||||
...popoverWidgetField,
|
||||
component: StockRescheculingPopover,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
|
||||
<div t-name="stock.PopoverStockRescheduling">
|
||||
<h6>Planning Issue</h6>
|
||||
<p>Preceding operations
|
||||
<t t-foreach="props.late_elements" t-as="late_element" t-key="late_element.id">
|
||||
<a t-out="late_element.name" t-on-click="openElement" href="#" t-att-element-id="late_element.id" t-att-element-model="late_element.model"/>,
|
||||
</t>
|
||||
planned on <t t-out="props.delay_alert_date"/>.</p>
|
||||
</div>
|
||||
</templates>
|
||||
8
frontend/stock/static/src/xml/inventory_lines.xml
Normal file
8
frontend/stock/static/src/xml/inventory_lines.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="InventoryLines.Buttons">
|
||||
<button type="button" class='btn btn-primary o_button_validate_inventory'>
|
||||
Validate Inventory
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
16
frontend/stock/static/src/xml/report_stock_reception.xml
Normal file
16
frontend/stock/static/src/xml/report_stock_reception.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="reception_report_buttons">
|
||||
<button
|
||||
name="assign_all_link"
|
||||
class="btn btn-secondary o_report_reception_assign o_assign_all">
|
||||
Assign All
|
||||
</button>
|
||||
<button
|
||||
name="print_all_labels"
|
||||
class="btn btn-secondary o_print_label o_print_label_all">
|
||||
Print Labels
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
Reference in New Issue
Block a user