Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,146 @@
odoo.define('sale_stock.QtyAtDateWidget', function (require) {
"use strict";
var core = require('web.core');
var QWeb = core.qweb;
var Widget = require('web.Widget');
var widget_registry = require('web.widget_registry');
var utils = require('web.utils');
var _t = core._t;
var time = require('web.time');
var QtyAtDateWidget = Widget.extend({
template: 'sale_stock.qtyAtDate.Legacy',
events: _.extend({}, Widget.prototype.events, {
'click .fa-area-chart': '_onClickButton',
}),
/**
* @override
* @param {Widget|null} parent
* @param {Object} params
*/
init: function (parent, params) {
this.data = params.data;
this.fields = params.fields;
this._updateData();
this._super(parent);
},
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self._setPopOver();
});
},
_updateData: function() {
// add some data to simplify the template
if (this.data.scheduled_date) {
var qty_to_deliver = utils.round_decimals(this.data.qty_to_deliver, this.fields.qty_to_deliver.digits[1]);
if (this.data.state === 'sale') {
this.data.will_be_fulfilled = utils.round_decimals(this.data.free_qty_today, this.fields.free_qty_today.digits[1]) >= qty_to_deliver
} else {
this.data.will_be_fulfilled = utils.round_decimals(this.data.virtual_available_at_date, this.fields.virtual_available_at_date.digits[1]) >= qty_to_deliver
}
this.data.will_be_late = this.data.forecast_expected_date && this.data.forecast_expected_date > this.data.scheduled_date;
if (['draft', 'sent'].includes(this.data.state)){
// Moves aren't created yet, then the forecasted is only based on virtual_available of quant
this.data.forecasted_issue = !this.data.will_be_fulfilled && !this.data.is_mto;
} else {
// Moves are created, using the forecasted data of related moves
this.data.forecasted_issue = !this.data.will_be_fulfilled || this.data.will_be_late;
}
}
},
updateState: function (state) {
this.$el.popover('dispose');
var candidate = state.data[this.getParent().currentRow];
if (candidate) {
this.data = candidate.data;
this._updateData();
this.renderElement();
this._setPopOver();
}
},
/**
* Redirect to the product graph view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
async _openForecast(ev) {
ev.stopPropagation();
// TODO: in case of kit product, the forecast view should show the kit's components (get_component)
// The forecast_report doesn't not allow for now multiple products
var action = await this._rpc({
model: 'product.product',
method: 'action_product_forecast_report',
args: [[this.data.product_id.data.id]]
});
action.context = {
active_model: 'product.product',
active_id: this.data.product_id.data.id,
warehouse: this.data.warehouse_id && this.data.warehouse_id.res_id,
move_to_match_ids: this.data.move_ids.res_ids,
sale_line_to_match_id: this.data.id,
};
return this.do_action(action);
},
_getContent() {
if (!this.data.scheduled_date) {
return;
}
this.data.delivery_date = this.data.scheduled_date.clone().add(this.getSession().getTZOffset(this.data.scheduled_date), 'minutes').format(time.getLangDateFormat());
if (this.data.forecast_expected_date) {
this.data.forecast_expected_date_str = this.data.forecast_expected_date.clone().add(this.getSession().getTZOffset(this.data.forecast_expected_date), 'minutes').format(time.getLangDateFormat());
}
const $content = $(QWeb.render('sale_stock.QtyDetailPopOver.Legacy', {
data: this.data,
}));
$content.on('click', '.action_open_forecast', this._openForecast.bind(this));
return $content;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Set a bootstrap popover on the current QtyAtDate widget that display available
* quantity.
*/
_setPopOver() {
const $content = this._getContent();
if (!$content) {
return;
}
const options = {
content: $content,
html: true,
placement: 'left',
title: _t('Availability'),
trigger: 'focus',
delay: {'show': 0, 'hide': 100 },
};
this.$el.popover(options);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
_onClickButton: function () {
// We add the property special click on the widget link.
// This hack allows us to trigger the popover (see _setPopOver) without
// triggering the _onRowClicked that opens the order line form view.
this.$el.find('.fa-area-chart').prop('special_click', true);
},
});
widget_registry.add('qty_at_date_widget', QtyAtDateWidget);
return QtyAtDateWidget;
});

View file

@ -0,0 +1,67 @@
<templates>
<div t-name="sale_stock.qtyAtDate.Legacy">
<div t-att-class="!widget.data.display_qty_widget ? 'invisible' : ''">
<a tabindex="0" t-attf-class="fa fa-area-chart {{ widget.data.forecasted_issue ? 'text-danger' : 'text-primary' }}"/>
</div>
</div>
<div t-name="sale_stock.QtyDetailPopOver.Legacy">
<table class="table table-borderless table-sm">
<tbody>
<t t-if="!data.is_mto and ['draft', 'sent'].includes(data.state)">
<tr>
<td><strong>Forecasted Stock</strong><br /><small>On <span t-esc="data.delivery_date"/></small></td>
<td><b t-esc='data.virtual_available_at_date'/>
<t t-esc='data.product_uom.data.display_name'/></td>
</tr>
<tr>
<td><strong>Available</strong><br /><small>All planned operations included</small></td>
<td><b t-esc='data.free_qty_today' t-att-class="!data.will_be_fulfilled ? 'text-danger': ''"/>
<t t-esc='data.product_uom.data.display_name'/></td>
</tr>
</t>
<t t-elif="data.is_mto and ['draft', 'sent'].includes(data.state)">
<tr>
<td><strong>Expected Delivery</strong></td>
<td class="oe-right"><span t-esc="data.delivery_date"/></td>
</tr>
<tr>
<p>This product is replenished on demand.</p>
</tr>
</t>
<t t-elif="data.state == 'sale'">
<tr>
<td>
<strong>Reserved</strong><br/>
</td>
<td style="min-width: 50px; text-align: right;">
<b t-esc='data.qty_available_today'/> <t t-esc='data.product_uom.data.display_name'/>
</td>
</tr>
<tr t-if="data.qty_available_today &lt; data.qty_to_deliver">
<td>
<span t-if="data.will_be_fulfilled and data.forecast_expected_date_str">
Remaining demand available at <b t-esc="data.forecast_expected_date_str" t-att-class="data.scheduled_date &lt; data.forecast_expected_date ? 'text-danger' : ''"/>
</span>
<span t-elif="!data.will_be_fulfilled and data.forecast_expected_date_str" class="text-danger">
No enough future availaibility
</span>
<span t-elif="!data.will_be_fulfilled" class="text-danger">
No future availaibility
</span>
<span t-else="">
Available in stock
</span>
</td>
</tr>
</t>
</tbody>
</table>
<button t-if="!data.is_mto" class="text-start btn btn-link action_open_forecast"
type="button">
<i class="fa fa-fw o_button_icon fa-arrow-right"></i>
View Forecast
</button>
</div>
</templates>

View file

@ -0,0 +1,11 @@
/** @odoo-module **/
import { formatMonetary } from "@web/views/fields/formatters";
import { patch } from '@web/core/utils/patch';
import { ForecastedDetails } from "@stock/stock_forecasted/forecasted_details";
patch(ForecastedDetails.prototype, 'sale_stock.ForecastedDetails', {
_formatMonetary(num, currencyId){
return formatMonetary(num,{ currencyId: currencyId});
}
});

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="sale_stock.ForecastedDetails" owl="1" t-inherit="stock.ForecastedDetails" t-inherit-mode="extension">
<xpath expr="//tr[@name='draft_picking_out']" position="after">
<tr t-if="props.docs.draft_sale_qty" name="draft_so_out" t-attf-class="#{props.docs.draft_sale_orders_matched and 'o_grid_match table-info'}">
<td colspan="2">Quotations</td>
<td t-out="_formatFloat(-props.docs.draft_sale_qty)" class="text-end"/>
<td>
<t t-foreach="props.docs.draft_sale_orders" t-as="sale_order" t-key="sale_order_index">
<t t-if="sale_order_index > 0"> | </t>
<a t-attf-href="#" t-out="sale_order.name"
class="fw-bold"
t-on-click.prevent="() => this.props.openView('sale.order', 'form', sale_order.id)"/>
</t>
</td>
</tr>
</xpath>
<xpath expr="//td[@name='usedby_cell']" position="inside">
<span t-if="line.move_out and line.move_out.picking_id and line.move_out.picking_id.sale_id">
| <span t-out="_formatMonetary(line.move_out.picking_id.sale_id.amount_untaxed, line.move_out.picking_id.sale_id.currency_id.id)" class="fw-bold"/>
| <span t-out="line.move_out.picking_id.sale_id.partner_id.name"/>
</span>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,92 @@
/** @odoo-module **/
import { formatDateTime } from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { usePopover } from "@web/core/popover/popover_hook";
const { Component, EventBus, onWillRender } = owl;
export class QtyAtDatePopover extends Component {
setup() {
this.actionService = useService("action");
}
openForecast() {
this.actionService.doAction("stock.stock_replenishment_product_product_action", {
additionalContext: {
active_model: 'product.product',
active_id: this.props.record.data.product_id[0],
warehouse: this.props.record.data.warehouse_id && this.props.record.data.warehouse_id[0],
move_to_match_ids: this.props.record.data.move_ids.records.map(record => record.data.id),
sale_line_to_match_id: this.props.record.data.id,
},
});
}
}
QtyAtDatePopover.template = "sale_stock.QtyDetailPopOver";
export class QtyAtDateWidget extends Component {
setup() {
this.bus = new EventBus();
this.popover = usePopover();
this.closePopover = null;
this.calcData = {};
onWillRender(() => {
this.initCalcData();
})
}
initCalcData() {
// calculate data not in record
const { data } = this.props.record;
if (data.scheduled_date) {
// TODO: might need some round_decimals to avoid errors
if (data.state === 'sale') {
this.calcData.will_be_fulfilled = data.free_qty_today >= data.qty_to_deliver;
} else {
this.calcData.will_be_fulfilled = data.virtual_available_at_date >= data.qty_to_deliver;
}
this.calcData.will_be_late = data.forecast_expected_date && data.forecast_expected_date > data.scheduled_date;
if (['draft', 'sent'].includes(data.state)) {
// Moves aren't created yet, then the forecasted is only based on virtual_available of quant
this.calcData.forecasted_issue = !this.calcData.will_be_fulfilled && !data.is_mto;
} else {
// Moves are created, using the forecasted data of related moves
this.calcData.forecasted_issue = !this.calcData.will_be_fulfilled || this.calcData.will_be_late;
}
}
}
updateCalcData() {
// popup specific data
const { data } = this.props.record;
if (!data.scheduled_date) {
return;
}
this.calcData.delivery_date = formatDateTime(data.scheduled_date, { format: localization.dateFormat });
if (data.forecast_expected_date) {
this.calcData.forecast_expected_date_str = formatDateTime(data.forecast_expected_date, { format: localization.dateFormat });
}
}
showPopup(ev) {
this.updateCalcData();
this.closePopover = this.popover.add(
ev.currentTarget,
this.constructor.components.Popover,
{bus: this.bus, record: this.props.record, calcData: this.calcData},
{
position: 'top',
}
);
this.bus.addEventListener('close-popover', this.closePopover);
}
}
QtyAtDateWidget.components = { Popover: QtyAtDatePopover };
QtyAtDateWidget.template = "sale_stock.qtyAtDate";
registry.category("view_widgets").add("qty_at_date_widget", QtyAtDateWidget);

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template id="template" xml:space="preserve">
<!-- TODO: rename this to QtyAtDate in master version -->
<t t-name="sale_stock.qtyAtDate" owl="1">
<div t-att-class="!props.record.data.display_qty_widget ? 'invisible' : ''">
<a t-att-tabindex="props.record.data.display_qty_widget ? '0' : '-1'" t-on-click="showPopup" t-attf-class="fa fa-area-chart {{ calcData.forecasted_issue ? 'text-danger' : 'text-primary' }}"/>
</div>
</t>
<!-- TODO: rename this to QtyAtDatePopup in master version -->
<t t-name="sale_stock.QtyDetailPopOver" owl="1">
<div>
<h6>Availability</h6>
<table class="table table-borderless table-sm">
<tbody>
<t t-if="!props.record.data.is_mto and ['draft', 'sent'].includes(props.record.data.state)">
<tr>
<td><strong>Forecasted Stock</strong><br/><small>On <span t-out="props.calcData.delivery_date"/></small></td>
<td><b t-out='props.record.data.virtual_available_at_date'/> <t t-out='props.record.data.product_uom[1]'/></td>
</tr>
<tr>
<td><strong>Available</strong><br /><small>All planned operations included</small></td>
<td><b t-out='props.record.data.free_qty_today' t-att-class="!props.calcData.will_be_fulfilled ? 'text-danger': ''"/> <t t-out='props.record.data.product_uom[1]'/></td>
</tr>
</t>
<t t-elif="props.record.data.is_mto and ['draft', 'sent'].includes(props.record.data.state)">
<tr>
<td><strong>Expected Delivery</strong></td>
<td class="oe-right"><span t-out="props.calcData.delivery_date"/></td>
</tr>
<tr>
<p>This product is replenished on demand.</p>
</tr>
</t>
<t t-elif="props.record.data.state == 'sale'">
<tr>
<td>
<strong>Reserved</strong><br/>
</td>
<td style="min-width: 50px; text-align: right;">
<b t-out='props.record.data.qty_available_today'/> <t t-out='props.record.data.product_uom[1]'/>
</td>
</tr>
<tr t-if="props.record.data.qty_available_today &lt; props.record.data.qty_to_deliver">
<td>
<span t-if="props.calcData.will_be_fulfilled and props.calcData.forecast_expected_date_str">
Remaining demand available at <b t-out="props.calcData.forecast_expected_date_str" t-att-class="props.record.data.scheduled_date &lt; props.record.data.forecast_expected_date ? 'text-danger' : ''"/>
</span>
<span t-elif="!props.calcData.will_be_fulfilled and props.calcData.forecast_expected_date_str" class="text-danger">
Not enough future availability
</span>
<span t-elif="!props.calcData.will_be_fulfilled" class="text-danger">
No future availability
</span>
<span t-else="">
Available in stock
</span>
</td>
</tr>
</t>
</tbody>
</table>
<button t-if="!props.record.data.is_mto" class="text-start btn btn-link"
type="button" t-on-click="openForecast">
<i class="fa fa-fw o_button_icon fa-arrow-right"></i>
View Forecast
</button>
</div>
</t>
</template>

View file

@ -0,0 +1,9 @@
<templates>
<div t-name="sale_stock.DelayAlertWidget" owl="1" class="m-1">
<p>The delivery
<t t-foreach="props.late_elements" t-as="late_element" t-key="late_element.id">
<a t-esc="late_element.name" href="#" t-on-click="openElement" t-att-element-id="late_element.id" t-att-element-model="late_element.model"/>,
</t> will be late.
</p>
</div>
</templates>