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:
Marc
2026-03-31 23:09:12 +02:00
parent 0ed29fe2fd
commit 8741282322
2933 changed files with 280644 additions and 264 deletions

View 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);

View File

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

View File

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

View File

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

View File

@@ -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 &amp;&amp; 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);

View 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>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

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

View File

@@ -0,0 +1,5 @@
.o_field_picking_type_dashboard_graph .o_dashboard_graph {
margin-bottom: 0;
margin-left: -16px;
margin-right: -16px;
}

View File

@@ -0,0 +1,3 @@
.o_forecast_widget_cell {
text-align: left !important;
}

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

View File

@@ -0,0 +1,8 @@
.hover-show .btn-show {
opacity: 0;
transition: opacity 0.1s;
}
.hover-show:hover .btn-show {
opacity: 1;
}

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

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

View File

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

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

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

View File

@@ -0,0 +1,3 @@
.btn[name="action_show_details"] {
border-width: 0;
}

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

View File

@@ -0,0 +1,8 @@
.o_stock_replenishment_info {
.o_field_widget.o_small {
display: inline;
input {
width: 40px;
}
}
}

View File

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

View File

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

View File

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

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

View File

@@ -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] &gt; 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] &gt; 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 &lt;= 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) &lt;= 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>

View File

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

View File

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

View File

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

View File

@@ -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 &lt; 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>

View File

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

View File

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

View 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);

View File

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

View 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");
}
}
});
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);

View 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>

View File

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

View File

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

View File

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

View 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);

View 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),
});

View 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>

View 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);

View 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>

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

View 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);

View 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>

View 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' &amp;&amp; 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>

View 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);

View 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);

View 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>

View 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);

View 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,
};
},
});

View 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>

View 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" },
],
});

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="stock.StockPickFrom">
<Many2One t-props="m2oProps"/>
</t>
</templates>

View File

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

View File

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

View 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>

View 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>