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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#B06161"/><stop offset="45.785%" stop-color="#984E4E"/><stop offset="100%" stop-color="#7C3838"/></linearGradient><path id="d" d="M48.466 42.033c0 .837-.277 1.548-.831 2.133L36.606 55.823c-.584.585-1.265.877-2.044.877-.793 0-1.467-.292-2.021-.877l-16.06-16.965c-.569-.585-1.051-1.382-1.448-2.393s-.595-1.935-.595-2.773v-9.857c0-.821.284-1.532.853-2.132.569-.6 1.243-.9 2.021-.9h9.344c.794 0 1.67.209 2.628.627.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156zM24.5 28.385c0-.838-.28-1.552-.842-2.145-.562-.592-1.24-.888-2.033-.888-.794 0-1.471.296-2.033.888-.561.593-.842 1.307-.842 2.145 0 .837.28 1.552.842 2.144.562.592 1.24.889 2.033.889.794 0 1.471-.297 2.033-.889.561-.592.842-1.307.842-2.144zm32.59 13.648c0 .837-.276 1.548-.83 2.133L45.23 55.823c-.584.585-1.265.877-2.044.877-.539 0-.98-.11-1.325-.332-.344-.22-.74-.576-1.19-1.066l10.557-11.136c.554-.585.83-1.296.83-2.133 0-.821-.276-1.54-.83-2.156l-16.06-16.919c-.57-.6-1.333-1.11-2.291-1.528-.958-.418-1.834-.628-2.628-.628h5.031c.794 0 1.67.21 2.628.628.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156z"/><path id="e" d="M48.466 40.033c0 .837-.277 1.548-.831 2.133L36.606 53.823c-.584.585-1.265.877-2.044.877-.793 0-1.467-.292-2.021-.877l-16.06-16.965c-.569-.585-1.051-1.382-1.448-2.393s-.595-1.935-.595-2.773v-9.857c0-.821.284-1.532.853-2.132.569-.6 1.243-.9 2.021-.9h9.344c.794 0 1.67.209 2.628.627.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156zM24.5 26.385c0-.838-.28-1.552-.842-2.145-.562-.592-1.24-.888-2.033-.888-.794 0-1.471.296-2.033.888-.561.593-.842 1.307-.842 2.145 0 .837.28 1.552.842 2.144.562.592 1.24.889 2.033.889.794 0 1.471-.297 2.033-.889.561-.592.842-1.307.842-2.144zm32.59 13.648c0 .837-.276 1.548-.83 2.133L45.23 53.823c-.584.585-1.265.877-2.044.877-.539 0-.98-.11-1.325-.332-.344-.22-.74-.576-1.19-1.066l10.557-11.136c.554-.585.83-1.296.83-2.133 0-.821-.276-1.54-.83-2.156l-16.06-16.919c-.57-.6-1.333-1.11-2.291-1.528-.958-.418-1.834-.628-2.628-.628h5.031c.794 0 1.67.21 2.628.628.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M4 69c-2 0-4-1-4-4V33.916L16.402 19h19.682L56.79 41 39.224 69H4z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,317 @@
odoo.define('product.generate_pricelist', function (require) {
'use strict';
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var FieldMany2One = require('web.relational_fields').FieldMany2One;
var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var _t = core._t;
var QtyTagWidget = Widget.extend({
template: 'product.report_pricelist_qty',
events: {
'click .o_remove_qty': '_onClickRemoveQty',
},
/**
* @override
*/
init: function (parent, defaulQuantities) {
this._super.apply(this, arguments);
this.quantities = defaulQuantities;
this.MAX_QTY = 5;
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Add a quantity when add(+) button clicked.
*
* @private
*/
_onClickAddQty: function () {
if (this.quantities.length >= this.MAX_QTY) {
this.displayNotification({ message: _.str.sprintf(
_t("At most %d quantities can be displayed simultaneously. Remove a selected quantity to add others."),
this.MAX_QTY
) });
return;
}
const qty = parseInt(this.$('.o_product_qty').val());
if (qty && qty > 0) {
// Check qty already exist
if (this.quantities.indexOf(qty) === -1) {
this.quantities.push(qty);
this.quantities = this.quantities.sort((a, b) => a - b);
this.trigger_up('qty_changed', {quantities: this.quantities});
this.renderElement();
} else {
this.displayNotification({
message: _.str.sprintf(_t("Quantity already present (%d)."), qty),
type: 'info'
});
}
} else {
this.displayNotification({ message: _t("Please enter a positive whole number") });
}
},
/**
* Remove quantity.
*
* @private
* @param {jQueryEvent} ev
*/
_onClickRemoveQty: function (ev) {
const qty = parseInt($(ev.currentTarget).closest('.badge').data('qty'));
this.quantities = this.quantities.filter(q => q !== qty);
this.trigger_up('qty_changed', {quantities: this.quantities});
this.renderElement();
},
});
var GeneratePriceList = AbstractAction.extend(StandaloneFieldManagerMixin, {
hasControlPanel: true,
events: {
'click .o_action': '_onClickAction',
'submit form': '_onSubmitForm',
},
custom_events: Object.assign({}, StandaloneFieldManagerMixin.custom_events, {
field_changed: '_onFieldChanged',
qty_changed: '_onQtyChanged',
}),
/**
* @override
*/
init: function (parent, params) {
this._super.apply(this, arguments);
StandaloneFieldManagerMixin.init.call(this);
this.context = params.context;
// in case the window got refreshed
if (params.params && params.params.active_ids && typeof(params.params.active_ids === 'string')) {
try {
this.context.active_ids = params.params.active_ids.split(',').map(id => parseInt(id));
this.context.active_model = params.params.active_model;
} catch(_e) {
console.log('unable to load ids from the url fragment 🙁');
}
}
if (!this.context.active_model) {
// started without an active module, assume product templates
this.context.active_model = 'product.template';
}
this.context.quantities = [1, 5, 10];
},
/**
* @override
*/
willStart: function () {
let getPricelist;
// started without a selected pricelist in context? just get the first one
if (this.context.default_pricelist) {
getPricelist = Promise.resolve([this.context.default_pricelist]);
} else {
getPricelist = this._rpc({
model: 'product.pricelist',
method: 'search',
args: [[]],
kwargs: {limit: 1}
});
}
const fieldSetup = getPricelist.then(pricelistIds => {
return this.model.makeRecord('report.product.report_pricelist', [{
name: 'pricelist_id',
type: 'many2one',
relation: 'product.pricelist',
value: pricelistIds[0],
}]);
}).then(recordID => {
const record = this.model.get(recordID);
this.many2one = new FieldMany2One(this, 'pricelist_id', record, {
mode: 'edit',
attrs: {
can_create: false,
can_write: false,
options: {no_open: true},
},
});
this._registerWidget(recordID, 'pricelist_id', this.many2one);
});
return Promise.all([fieldSetup, this._getHtml(), this._super()]);
},
/**
* @override
*/
start: function () {
this.controlPanelProps.cp_content = this._renderComponent();
const $content = this.controlPanelProps.cp_content;
$content["$searchview"][0].querySelector('.o_is_visible_title').addEventListener('click', this._onClickVisibleTitle.bind(this));
return this._super.apply(this, arguments).then(() => {
this.$('.o_content').html(this.reportHtml);
});
},
/**
* Include the current model (template/variant) in the state to allow refreshing without losing
* the proper context.
* @override
*/
getState: function () {
return {
active_model: this.context.active_model,
};
},
getTitle: function () {
return _t('Pricelist Report');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Returns the expected data for the report rendering call (html or pdf)
*
* @private
* @returns {Object}
*/
_prepareActionReportParams: function () {
return {
active_model: this.context.active_model,
active_ids: this.context.active_ids || '',
is_visible_title: this.context.is_visible_title || '',
pricelist_id: this.context.pricelist_id || '',
quantities: this.context.quantities || [1],
};
},
/**
* Get template to display report.
*
* @private
* @returns {Promise}
*/
_getHtml: function () {
return this._rpc({
model: 'report.product.report_pricelist',
method: 'get_html',
kwargs: {
data: this._prepareActionReportParams(),
context: this.context,
},
}).then(result => {
this.reportHtml = result;
});
},
/**
* Reload report.
*
* @private
* @returns {Promise}
*/
_reload: function () {
return this._getHtml().then(() => {
this.$('.o_content').html(this.reportHtml);
});
},
/**
* Render search view and print button.
*
* @private
*/
_renderComponent: function () {
const $buttons = $('<button>', {
class: 'btn btn-primary',
text: _t("Print"),
}).on('click', this._onClickPrint.bind(this));
const $searchview = $(QWeb.render('product.report_pricelist_search'));
this.many2one.appendTo($searchview.find('.o_pricelist'));
this.qtyTagWidget = new QtyTagWidget(this, this.context.quantities);
this.qtyTagWidget.replace($searchview.find('.o_product_qty'));
return { $buttons, $searchview };
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Checkbox is checked, the report title will show.
*
* @private
* @param {Event} ev
*/
_onClickVisibleTitle(ev) {
this.context.is_visible_title = ev.currentTarget.checked;
this._reload();
},
/**
* Open form view of particular record when link clicked.
*
* @private
* @param {jQueryEvent} ev
*/
_onClickAction: function (ev) {
ev.preventDefault();
this.do_action({
type: 'ir.actions.act_window',
res_model: $(ev.currentTarget).data('model'),
res_id: $(ev.currentTarget).data('res-id'),
views: [[false, 'form']],
target: 'self',
});
},
/**
* Print report in PDF when button clicked.
*
* @private
*/
_onClickPrint: function () {
return this.do_action({
type: 'ir.actions.report',
report_type: 'qweb-pdf',
report_name: 'product.report_pricelist',
report_file: 'product.report_pricelist',
data: this._prepareActionReportParams(),
});
},
/**
* Reload report when pricelist changed.
*
* @override
*/
_onFieldChanged: function (event) {
this.context.pricelist_id = event.data.changes.pricelist_id.id;
StandaloneFieldManagerMixin._onFieldChanged.apply(this, arguments);
this._reload();
},
/**
* Reload report when quantities changed.
*
* @private
* @param {OdooEvent} ev
* @param {integer[]} event.data.quantities
*/
_onQtyChanged: function (ev) {
this.context.quantities = ev.data.quantities;
this._reload();
},
_onSubmitForm: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this.qtyTagWidget._onClickAddQty();
},
});
core.action_registry.add('generate_pricelist', GeneratePriceList);
return {
GeneratePriceList,
QtyTagWidget
};
});

View file

@ -0,0 +1,83 @@
.o_label_sheet {
margin-left: -4mm;
margin-right: -4mm;
overflow: hidden;
width: 210mm;
height: 297mm;
page-break-before: always;
&.o_label_dymo {
font-size:90%;
width: 57mm;
height: 32mm;
}
div {
padding: 2px 4px;
}
div.o_label_small_text {
font-size: 60%;
line-height: 130%;
}
div.o_label_name {
background-color: ghostwhite;
height: 3em;
overflow: hidden;
}
div.o_label_full {
overflow: hidden;
padding: 0;
margin: auto;
}
div.o_label_left_column {
float: left;
font-size: .6em;
overflow:hidden;
width: 40%;
&.o_label_full_with {
width: 100%
}
}
div.o_label_right_column {
float: right;
}
div.o_label_small_barcode {
font-size: .6em;
padding: 0 4px;
line-height: normal;
}
strong.o_label_price {
font-size: 2em;
}
strong.o_label_price_medium {
font-size: 1.3em;
line-height: normal;
padding: 0;
padding-right: 2mm;
}
strong.o_label_price_small {
font-size: 0.9em;
padding: 0 4px;
padding-right: 2mm;
}
div.o_label_extra_data {
overflow: hidden;
height: 2.5em;
padding: 0;
.img {
max-height: 100%;
max-width: 100%;
}
}
div.o_label_clear {
clear: both;
}
// generic 4x12 label w/ all same size font
div.o_label_4x12 {
padding:0;
line-height:1;
font-size:55%;
overflow:hidden;
white-space:nowrap;
text-overflow: ellipsis;
}
}

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="product.report_pricelist_qty">
<span>
<div class="input-group flex-nowrap w-75">
<input type="number" name="qty_to_add" class="o_input o_product_qty form-control text-end w-auto" value="1" min="1"/>
<button class="btn btn-secondary o_add_qty text-end form-control" type="submit" title="Add a quantity">
<i class="fa fa-plus"/>
</button>
</div>
<span class="o_badges">
<t t-set="quantities" t-value="widget.quantities"/>
<t t-call="product.report_pricelist_qty_badges"/>
</span>
</span>
</t>
<t t-name="product.report_pricelist_search">
<form class="d-flex justify-content-around align-items-center o_pricelist_report_form">
<div>
<label class="fw-bold">Pricelist:</label>
<span class="o_pricelist"/>
</div>
<div class="d-flex align-items-center">
<label class="fw-bold mb-4" for="qty_to_add">Quantities:</label>
<div class="o_product_qty"/>
</div>
<div class="form-check">
<input class="form-check-input o_is_visible_title ms-2" type="checkbox"/>
<label class="form-check-label">Display Pricelist</label>
</div>
</form>
</t>
<t t-name="product.report_pricelist_qty_badges">
<t t-foreach="quantities" t-as="qty">
<span class="badge rounded-pill border" t-att-data-qty="qty">
<t t-esc="qty"/>
<i class="fa fa-close o_remove_qty" title="Remove quantity"/>
</span>
</t>
</t>
</templates>

View file

@ -0,0 +1,139 @@
odoo.define('product.pricelist.report.tests', function (require) {
"use strict";
const GeneratePriceList = require('product.generate_pricelist').GeneratePriceList;
const testUtils = require('web.test_utils');
const { createWebClient, doAction } = require('@web/../tests/webclient/helpers');
const { getFixture, patchWithCleanup } = require("@web/../tests/helpers/utils");
let serverData;
QUnit.module('Product Pricelist', {
beforeEach: function () {
this.data = {
'product.product': {
fields: {
id: {type: 'integer'}
},
records: [{
id: 42,
display_name: "Customizable Desk"
}]
},
'product.pricelist': {
fields: {
id: {type: 'integer'}
},
records: [{
id: 1,
display_name: "Public Pricelist"
}, {
id: 2,
display_name: "Test"
}]
}
};
serverData = { models: this.data };
},
}, function () {
QUnit.test('Pricelist Client Action', async function (assert) {
assert.expect(23);
let Qty = [1, 5, 10]; // default quantities
patchWithCleanup(GeneratePriceList.prototype, {
_onFieldChanged: function (event) {
assert.step('field_changed');
return this._super.apply(this, arguments);
},
_onQtyChanged: function (event) {
assert.deepEqual(event.data.quantities, Qty.sort((a, b) => a - b), "changed quantity should be same.");
assert.step('qty_changed');
return this._super.apply(this, arguments);
},
});
const mockRPC = (route, args) => {
if (route === '/web/dataset/call_kw/report.product.report_pricelist/get_html') {
return Promise.resolve("");
}
};
const target = getFixture();
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
id: 1,
name: 'Generate Pricelist',
tag: 'generate_pricelist',
type: 'ir.actions.client',
context: {
'default_pricelist': 1,
'active_ids': [42],
'active_id': 42,
'active_model': 'product.product'
}
});
// checking default pricelist
assert.strictEqual($(target).find('.o_field_many2one input').val(), "Public Pricelist",
"should have default pricelist");
// changing pricelist
await testUtils.fields.many2one.clickOpenDropdown("pricelist_id");
await testUtils.fields.many2one.clickItem("pricelist_id", "Test");
// check wherther pricelist value has been updated or not. along with that check default quantities should be there.
assert.strictEqual($(target).find('.o_field_many2one input').val(), "Test",
"After pricelist change, the pricelist_id field should be updated");
assert.strictEqual($(target).find('.o_badges > .badge').length, 3,
"There should be 3 default Quantities");
// existing quantity can not be added.
await testUtils.dom.click($(target).find('.o_add_qty'));
let notificationElement = document.body.querySelector('.o_notification_manager .o_notification');
assert.strictEqual(notificationElement.querySelector('.o_notification_content').textContent,
"Quantity already present (1).", "Existing Quantity can not be added");
assert.hasClass(notificationElement, "border-info");
// adding few more quantities to check.
$(target).find('.o_product_qty').val(2);
Qty.push(2);
await testUtils.dom.click($(target).find('.o_add_qty'));
$(target).find('.o_product_qty').val(3);
Qty.push(3);
await testUtils.dom.click($(target).find('.o_add_qty'));
// should not be added more then 5 quantities.
$(target).find('.o_product_qty').val(4);
await testUtils.dom.click($(target).find('.o_add_qty'));
notificationElement = document.body.querySelector('.o_notification_manager .o_notification:nth-child(2)');
assert.strictEqual(notificationElement.querySelector('.o_notification_content').textContent,
"At most 5 quantities can be displayed simultaneously. Remove a selected quantity to add others.",
"Can not add more then 5 quantities");
assert.hasClass(notificationElement, "border-warning");
// removing all the quantities should work
Qty.pop(10);
await testUtils.dom.click($(target).find('.o_badges .badge:contains("10") .o_remove_qty'));
Qty.pop(5);
await testUtils.dom.click($(target).find('.o_badges .badge:contains("5") .o_remove_qty'));
Qty.pop(3);
await testUtils.dom.click($(target).find('.o_badges .badge:contains("3") .o_remove_qty'));
Qty.pop(2);
await testUtils.dom.click($(target).find('.o_badges .badge:contains("2") .o_remove_qty'));
Qty.pop(1);
await testUtils.dom.click($(target).find('.o_badges .badge:contains("1") .o_remove_qty'));
assert.verifySteps([
'field_changed',
'qty_changed',
'qty_changed',
'qty_changed',
'qty_changed',
'qty_changed',
'qty_changed',
'qty_changed'
]);
});
}
);
});