mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-25 06:52:02 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -1,19 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-inherit="project.ProjectMilestone" t-inherit-mode="extension" owl="1">
|
||||
<t t-inherit="project.ProjectMilestone" t-inherit-mode="extension">
|
||||
<xpath expr="//t[@t-esc='milestone.name']" position="replace">
|
||||
<span>
|
||||
<t t-esc="milestone.name"/>
|
||||
<span t-if="milestone.allow_billable && milestone.quantity_percentage && !milestone.sale_line_name" class="fst-italic text-muted">
|
||||
<span t-if="milestone.allow_billable && milestone.quantity_percentage && !milestone.sale_line_display_name" class="fst-italic text-muted">
|
||||
(<t t-esc="(100 * milestone.quantity_percentage).toFixed(2)"/>%)
|
||||
</span>
|
||||
</span>
|
||||
<span t-if="milestone.allow_billable" t-attf-class="fst-italic {{state.colorClass || 'text-muted'}} ms-2">
|
||||
<t t-if="milestone.sale_line_name" t-esc="milestone.sale_line_name"/>
|
||||
<span t-if="milestone.quantity_percentage && milestone.sale_line_name">
|
||||
<div t-if="milestone.allow_billable" t-attf-class="fst-italic small {{state.colorClass and ' opacity-75' || 'text-muted'}}">
|
||||
<t t-if="milestone.sale_line_display_name" t-esc="milestone.sale_line_display_name"/>
|
||||
<span t-if="milestone.quantity_percentage && milestone.sale_line_display_name">
|
||||
(<t t-esc="(100 * milestone.quantity_percentage).toFixed(2)"/>%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { patch } from "@web/core/utils/patch";
|
||||
import { ProjectProfitability } from "@project/components/project_right_side_panel/components/project_profitability";
|
||||
import { ProjectProfitabilitySection } from "@sale_project/components/project_right_side_panel/components/project_profitability_section";
|
||||
|
||||
patch(ProjectProfitability, {
|
||||
props: {
|
||||
...ProjectProfitability.props,
|
||||
projectId: Number,
|
||||
context: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
...ProjectProfitability.components,
|
||||
ProjectProfitabilitySection,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="sale_project.ProjectProfitability" t-inherit="project.ProjectProfitability" t-inherit-mode="extension">
|
||||
<xpath expr="//tr[hasclass('revenue_section')]" position="replace">
|
||||
<ProjectProfitabilitySection
|
||||
revenue="revenue"
|
||||
labels="props.labels"
|
||||
formatMonetary="props.formatMonetary.bind(this)"
|
||||
onProjectActionClick="props.onProjectActionClick.bind(this)"
|
||||
onClick="(params) => this.props.onProjectActionClick(params)"
|
||||
projectId="this.props.projectId"
|
||||
context="this.props.context"
|
||||
/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { formatFloat, formatFloatTime } from "@web/views/fields/formatters";
|
||||
|
||||
export class ProjectProfitabilitySection extends Component {
|
||||
|
||||
static props = {
|
||||
revenue: Object,
|
||||
labels: Object,
|
||||
formatMonetary: Function,
|
||||
onProjectActionClick: Function,
|
||||
onClick: Function,
|
||||
projectId: Number,
|
||||
context: Object,
|
||||
};
|
||||
static template = "sale_project.ProjectProfitabilitySection";
|
||||
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
this.state = useState({
|
||||
isFolded: true,
|
||||
displayLoadMore: null,
|
||||
});
|
||||
this.sale_items = [];
|
||||
}
|
||||
|
||||
get revenue() {
|
||||
return this.props.revenue;
|
||||
}
|
||||
|
||||
async toggleSaleItems() {
|
||||
if (this.state.displayLoadMore === null) {
|
||||
// first time the section is unfold, load the 5 first items.
|
||||
await this.onLoadMoreClick()
|
||||
}
|
||||
// the state change is done at the end to ensure the loaded data are present when the component is rendered
|
||||
this.state.isFolded = !this.state.isFolded;
|
||||
}
|
||||
|
||||
formatValue(value, unit) {
|
||||
return unit === "Hours" ? formatFloatTime(value) : formatFloat(value);
|
||||
}
|
||||
|
||||
_getOrmValue(offset, section_id) {
|
||||
return {
|
||||
function: "get_sale_items_data",
|
||||
args: [this.props.projectId, offset, 5, true, section_id],
|
||||
};
|
||||
}
|
||||
|
||||
async onLoadMoreClick() {
|
||||
const offset = this.sale_items.length;
|
||||
const orm_value = this._getOrmValue(offset, this.props.revenue.id);
|
||||
const newItems = await this.orm.call(
|
||||
"project.project",
|
||||
orm_value.function,
|
||||
orm_value.args,
|
||||
{
|
||||
context: this.props.context,
|
||||
}
|
||||
);
|
||||
this.sale_items = [...this.sale_items, ...newItems.sol_items];
|
||||
this.state.displayLoadMore = newItems.displayLoadMore;
|
||||
this.render();
|
||||
}
|
||||
|
||||
async onSaleItemActionClick(params) {
|
||||
if (params.resId && params.type !== "object") {
|
||||
const action = await this.actionService.loadAction(params.name, this.props.context);
|
||||
this.actionService.doAction({
|
||||
...action,
|
||||
res_id: params.resId,
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
} else {
|
||||
this.props.onProjectActionClick(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="sale_project.ProjectProfitabilitySection">
|
||||
<tr class="opacity-trigger-hover" t-att-class="{ 'table-active': !state.isFolded }">
|
||||
<t t-set="revenue_label" t-value="props.labels[revenue.id] or revenue.id"/>
|
||||
<td>
|
||||
<div class="position-relative d-flex gap-1">
|
||||
<t t-if="this.props.revenue.isSectionFoldable">
|
||||
<button class="o_group_caret btn btn-link d-flex gap-1 flex-grow-1 p-0 text-reset text-start" t-on-click="() => this.toggleSaleItems()">
|
||||
<i t-attf-class="fa fa-fw #{state.isFolded ? 'fa-caret-right' : 'fa-caret-down'} mt-1"/>
|
||||
<span t-out="revenue_label"/>
|
||||
</button>
|
||||
</t>
|
||||
<t t-esc="revenue_label" t-else=""/>
|
||||
<a
|
||||
class="revenue_section btn btn-link my-n2 py-2 text-action"
|
||||
t-if="revenue.action" href="#"
|
||||
t-on-click="() => this.props.onClick(revenue.action)"
|
||||
t-att-class="{ 'ms-auto': env.isSmall, 'opacity-0': !env.isSmall and state.isFolded, 'opacity-100-hover': !env.isSmall }"
|
||||
aria-label="Internal link"
|
||||
data-tooltip="Internal link"
|
||||
>
|
||||
<i class="oi oi-arrow-right"/>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td t-attf-class="text-end {{ revenue.invoiced + revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced + revenue.to_invoice)"/></td>
|
||||
<td t-attf-class="text-end {{ revenue.to_invoice === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.to_invoice)"/></td>
|
||||
<td t-attf-class="text-end {{ revenue.invoiced === 0 ? 'text-500' : ''}}"><t t-esc="props.formatMonetary(revenue.invoiced)"/></td>
|
||||
</tr>
|
||||
<t t-if="!state.isFolded and this.props.revenue.isSectionFoldable">
|
||||
<tr class="table-active">
|
||||
<td colspan="4" class="p-0 m-0">
|
||||
<table class="o_rightpanel_subtable table table-sm table-borderless table-hover mb-0 border-bottom">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4">Sales Order Items</th>
|
||||
<th class="text-end">Sold</th>
|
||||
<th class="text-end">Delivered</th>
|
||||
<th class="text-end">Invoiced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="sale_items" t-as="sale_item" t-key="sale_item.id">
|
||||
<t t-set="uom_name" t-value="sale_item.product_uom_id and sale_item.product_uom_id[1]"/>
|
||||
<td class="ps-4">
|
||||
<a t-if="sale_item.action" href="#" t-on-click="() => this.onSaleItemActionClick(sale_item.action)">
|
||||
<t t-esc="sale_item.display_name"/>
|
||||
</a>
|
||||
<t t-else="" t-esc="sale_item.display_name"/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex d-md-block flex-column">
|
||||
<t t-esc="formatValue(sale_item.product_uom_qty, uom_name)"/>
|
||||
<small t-esc="uom_name" class="ms-md-1 text-muted"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex d-md-block flex-column">
|
||||
<t t-esc="formatValue(sale_item.qty_delivered, uom_name)"/>
|
||||
<small t-esc="uom_name" class="ms-md-1 text-muted"/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex d-md-block flex-column">
|
||||
<t t-esc="formatValue(sale_item.qty_invoiced, uom_name)"/>
|
||||
<small t-esc="uom_name" class="ms-md-1 text-muted"/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr t-if="state.displayLoadMore">
|
||||
<td class="pt-0 pb-3" colspan="4">
|
||||
<a class="btn btn-link" t-on-click="() => this.onLoadMoreClick(revenue)">
|
||||
Load more <i class="fa fa-arrow-circle-down ms-1"/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,55 +1,9 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { patch } from '@web/core/utils/patch';
|
||||
import { formatFloatTime, formatFloat } from "@web/views/fields/formatters";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { ProjectRightSidePanel } from '@project/components/project_right_side_panel/project_right_side_panel';
|
||||
|
||||
patch(ProjectRightSidePanel.prototype, '@sale_project/components/project_right_side_panel/project_right_side_panel', {
|
||||
async _loadAdditionalSalesOrderItems() {
|
||||
const offset = this.state.data.sale_items.data.length;
|
||||
const totalRecords = this.state.data.sale_items.total;
|
||||
const limit = totalRecords - offset <= 5 ? totalRecords - offset : 5;
|
||||
const saleOrderItems = await this.orm.call(
|
||||
'project.project',
|
||||
'get_sale_items_data',
|
||||
[this.projectId, undefined, offset, limit],
|
||||
{
|
||||
context: this.context,
|
||||
},
|
||||
);
|
||||
this.state.data.sale_items.data = [...this.state.data.sale_items.data, ...saleOrderItems];
|
||||
patch(ProjectRightSidePanel.prototype, {
|
||||
|
||||
get panelVisible() {
|
||||
return super.panelVisible || this.state.data.show_sale_items;
|
||||
},
|
||||
|
||||
async onLoadSalesOrderLinesClick() {
|
||||
const saleItems = this.state.data.sale_items;
|
||||
if (saleItems && saleItems.total > saleItems.data.length) {
|
||||
await this._loadAdditionalSalesOrderItems();
|
||||
}
|
||||
},
|
||||
|
||||
formatValue(value, unit) {
|
||||
return unit === 'Hours' ? formatFloatTime(value) : formatFloat(value);
|
||||
},
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} params
|
||||
*/
|
||||
async onSaleItemActionClick(params) {
|
||||
if (params.resId && params.type !== 'object') {
|
||||
const action = await this.actionService.loadAction(params.name, this.context);
|
||||
this.actionService.doAction({
|
||||
...action,
|
||||
res_id: params.resId,
|
||||
views: [[false, 'form']]
|
||||
});
|
||||
} else {
|
||||
this.onProjectActionClick(params);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,56 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="sale_project.ProjectRightSidePanel" t-inherit="project.ProjectRightSidePanel" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//ProjectRightSidePanelSection[@name="'profitability'"]" position="before">
|
||||
<ProjectRightSidePanelSection
|
||||
name="'sales'"
|
||||
show="state.data.sale_items and state.data.sale_items.total > 0"
|
||||
>
|
||||
<t t-set-slot="title" owl="1">
|
||||
Sales
|
||||
</t>
|
||||
<table class="table table-striped table-hover mb-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sales Order Items</th>
|
||||
<th class="text-end">Sold</th>
|
||||
<th class="text-end">Delivered</th>
|
||||
<th class="text-end">Invoiced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="state.data.sale_items.data" t-as="sale_item" t-key="sale_item.id">
|
||||
<t t-set="uom_name" t-value="sale_item.product_uom and sale_item.product_uom[1]"/>
|
||||
<td>
|
||||
<t t-set="sol_name" t-value="sale_item.display_name"/>
|
||||
<a t-if="sale_item.action" class="o_rightpanel_button" href="#" t-on-click="() => this.onSaleItemActionClick(sale_item.action)">
|
||||
<t t-esc="sol_name"/>
|
||||
</a>
|
||||
<t t-else="" t-esc="sol_name"/>
|
||||
</td>
|
||||
<td class="text-end align-middle"><t t-esc="formatValue(sale_item.product_uom_qty, uom_name)"/> <t t-esc="uom_name"/></td>
|
||||
<td class="text-end align-middle"><t t-esc="formatValue(sale_item.qty_delivered, uom_name)"/> <t t-esc="uom_name"/></td>
|
||||
<td class="text-end align-middle"><t t-esc="formatValue(sale_item.qty_invoiced, uom_name)"/> <t t-esc="uom_name"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="o_rightpanel_nohover border-0" t-if="state.data.sale_items.total > state.data.sale_items.data.length">
|
||||
<td class="pb-0 border-0 text-center" colspan="4">
|
||||
<a class="btn btn-link" t-on-click="onLoadSalesOrderLinesClick">
|
||||
Load more
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-else="">
|
||||
<td class="pb-0 border-0 shadow-none text-center" colspan="4">
|
||||
<small class="text-muted">All items have been loaded</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</ProjectRightSidePanelSection>
|
||||
<t t-name="sale_project.ProjectRightSidePanel" t-inherit="project.ProjectRightSidePanel" t-inherit-mode="extension">
|
||||
<xpath expr="//ProjectProfitability" position="replace">
|
||||
<ProjectProfitability
|
||||
t-if="showProjectProfitability"
|
||||
data="state.data.profitability_items"
|
||||
labels="state.data.profitability_labels"
|
||||
formatMonetary="formatMonetary.bind(this)"
|
||||
onProjectActionClick="onProjectActionClick.bind(this)"
|
||||
onClick="(params) => this.onProjectActionClick(params)"
|
||||
projectId="projectId"
|
||||
context="context"
|
||||
/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
|
||||
import { user } from "@web/core/user";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class SoLineCreateButton extends Component {
|
||||
static template = "sale_timesheet.SoLineCreateButton";
|
||||
static components = { Many2One };
|
||||
static props = { ...Many2OneField.props };
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
openSalesOrderDialog() {
|
||||
const { context, record } = this.props;
|
||||
this.dialogService.add(FormViewDialog, {
|
||||
title: "Create Sales Order",
|
||||
resModel: "sale.order",
|
||||
context: {
|
||||
...context,
|
||||
form_view_ref: context.so_form_view_ref,
|
||||
default_company_id: context.default_company_id || user.activeCompany.id,
|
||||
default_user_id: user.userId,
|
||||
hide_pdf_quote_builder: true,
|
||||
},
|
||||
onRecordSaved: async (rec) => {
|
||||
const service_line_id = await rec.model.orm.call(
|
||||
"sale.order",
|
||||
"get_first_service_line",
|
||||
[rec.resId]
|
||||
);
|
||||
record.update({ sale_line_id: { id: service_line_id[0] } });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
return computeM2OProps(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("so_line_create_button", {
|
||||
...buildM2OFieldDescription(SoLineCreateButton),
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
body:not(.o_touch_device) .o_field_so_line_create_button {
|
||||
&:not(:hover):not(:focus-within) {
|
||||
& .o_many2one:not(:hover) ~ a {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
word-break: keep-all !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="sale_timesheet.SoLineCreateButton">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<Many2One t-props="m2oProps" cssClass="'w-100'"/>
|
||||
<a
|
||||
t-if="!props.readonly and !props.record.data.sale_line_id"
|
||||
type="button"
|
||||
class="ms-3 d-inline-flex align-items-center"
|
||||
tabindex="-1"
|
||||
draggable="false"
|
||||
aria-label="Create Sales Order"
|
||||
data-tooltip="Create Sales Order"
|
||||
t-on-click="openSalesOrderDialog"
|
||||
>
|
||||
<i class="fa fa-plus"/>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
.o_bottom_sheet .o_bottom_sheet_sheet {
|
||||
.o-dropdown-item {
|
||||
.oe_stat_button[name="action_view_project_ids"] {
|
||||
> .o_stat_info {
|
||||
flex-direction: column;
|
||||
|
||||
> .o_row {
|
||||
display: flex;
|
||||
gap: inherit;
|
||||
padding-block: map-get($spacers, 1);
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { patch } from "@web/core/utils/patch";
|
||||
import { ProjectTaskListRenderer } from "@project/views/project_task_list/project_task_list_renderer";
|
||||
|
||||
patch(ProjectTaskListRenderer.prototype, {
|
||||
isCellReadonly(column, record) {
|
||||
let readonly = false;
|
||||
if (column.name === "sale_line_id") {
|
||||
readonly = !this.haveAllSelectedTasksSameField('partner_id');
|
||||
}
|
||||
return readonly || super.isCellReadonly(column, record);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { queryFirst } from "@odoo/hoot-dom";
|
||||
import { defineModels, mountView } from "@web/../tests/web_test_helpers";
|
||||
import { defineProjectModels } from "@project/../tests/project_models";
|
||||
import { ProductProduct, ProjectMilestone, SaleOrderLine } from "./project_task_model"
|
||||
|
||||
describe.current.tags("desktop");
|
||||
defineModels([SaleOrderLine, ProductProduct]);
|
||||
defineProjectModels();
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectMilestone._records = [
|
||||
{ id: 1, product_uom_qty: -1, quantity_percentage: -25 },
|
||||
{ id: 2, product_uom_qty: 5, quantity_percentage: 125 },
|
||||
{ id: 3, product_uom_qty: 2, quantity_percentage: 0.02 },
|
||||
];
|
||||
});
|
||||
|
||||
const mountViewParams = {
|
||||
resModel: "project.milestone",
|
||||
type: "form",
|
||||
arch: `
|
||||
<form>
|
||||
<field name="product_uom_qty" decoration-danger="quantity_percentage < 0 or 1 < quantity_percentage"/>
|
||||
<field name="quantity_percentage" decoration-danger="quantity_percentage < 0 or 1 < quantity_percentage"/>
|
||||
</form>
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to mount the view and test if an element has the `text-danger` class.
|
||||
* @param {number} resId.
|
||||
* @param {boolean} shouldHaveClass.
|
||||
*/
|
||||
async function _testElementClass(resId, shouldHaveClass) {
|
||||
mountViewParams.resId = resId;
|
||||
await mountView(mountViewParams);
|
||||
|
||||
const quantityElement = queryFirst('#quantity_percentage_0').parentElement;
|
||||
const productUomQtyElement = queryFirst('#product_uom_qty_0').parentElement;
|
||||
|
||||
if (shouldHaveClass) {
|
||||
expect(quantityElement).toHaveClass("text-danger");
|
||||
expect(productUomQtyElement).toHaveClass("text-danger");
|
||||
} else {
|
||||
expect(quantityElement).not.toHaveClass("text-danger");
|
||||
expect(productUomQtyElement).not.toHaveClass("text-danger");
|
||||
}
|
||||
}
|
||||
|
||||
test("Quantities have text-danger if quantity < 0", async () => {
|
||||
await _testElementClass(1, true);
|
||||
});
|
||||
|
||||
test("Quantities have text-danger if quantity > 100", async () => {
|
||||
await _testElementClass(2, true);
|
||||
});
|
||||
|
||||
test("Quantities don't have text-danger if quantity >= 0", async () => {
|
||||
await _testElementClass(3, false);
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { check, queryAll, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { defineModels, mountView } from "@web/../tests/web_test_helpers";
|
||||
import { mailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { defineProjectModels, projectModels } from "@project/../tests/project_models";
|
||||
import { ProductProduct, SaleOrderLine } from "./project_task_model";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
defineModels([SaleOrderLine, ProductProduct]);
|
||||
defineProjectModels();
|
||||
|
||||
test("cannot edit sale_line_id when partners are different", async () => {
|
||||
mailModels.ResPartner._records = [
|
||||
{ id: 101, name: "Acme Corporation" },
|
||||
{ id: 102, name: "Azure Interior" },
|
||||
...mailModels.ResPartner._records,
|
||||
];
|
||||
|
||||
projectModels.ProjectTask._records = [
|
||||
{ id: 1, partner_id: 101, sale_line_id: 1 },
|
||||
{ id: 2, partner_id: 102, sale_line_id: 2 },
|
||||
];
|
||||
|
||||
SaleOrderLine._records = [
|
||||
{ id: 1, name: "order1" },
|
||||
{ id: 2, name: "order2" },
|
||||
];
|
||||
|
||||
await mountView({
|
||||
resModel: "project.task",
|
||||
type: "list",
|
||||
arch: `
|
||||
<list multi_edit="1" js_class="project_task_list">
|
||||
<field name="partner_id"/>
|
||||
<field name="sale_line_id"/>
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
const [firstRow, secondRow] = queryAll(".o_data_row");
|
||||
|
||||
await check(".o_list_record_selector input", { root: firstRow });
|
||||
await animationFrame();
|
||||
expect(queryOne("[name=sale_line_id]", { root: firstRow })).not.toHaveClass("o_readonly_modifier", {
|
||||
message: "None of the fields should be readonly",
|
||||
});
|
||||
expect(queryOne("[name=sale_line_id]", { root: secondRow })).not.toHaveClass("o_readonly_modifier", {
|
||||
message: "None of the fields should be readonly",
|
||||
});
|
||||
|
||||
await check(".o_list_record_selector input", { root: secondRow });
|
||||
await animationFrame();
|
||||
expect(queryOne("[name=sale_line_id]", { root: firstRow })).toHaveClass("o_readonly_modifier", {
|
||||
message: "The sale_ine_id should be readonly",
|
||||
});
|
||||
expect(queryOne("[name=sale_line_id]", { root: secondRow })).toHaveClass("o_readonly_modifier", {
|
||||
message: "The sale_ine_id should be readonly",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { fields, models } from "@web/../tests/web_test_helpers";
|
||||
import { projectModels } from "@project/../tests/project_models";
|
||||
|
||||
|
||||
export class ProjectTask extends projectModels.ProjectTask {
|
||||
_name = "project.task";
|
||||
|
||||
sale_line_id = fields.Many2one({ string: "Sale Order Line", relation: "sale.order.line" });
|
||||
}
|
||||
|
||||
export class ProjectMilestone extends projectModels.ProjectMilestone {
|
||||
_name = "project.milestone";
|
||||
|
||||
product_uom_qty = fields.Float({ string: "Quantity" });
|
||||
quantity_percentage = fields.Float({ string: "Percentage" });
|
||||
}
|
||||
|
||||
export class SaleOrder extends models.Model {
|
||||
_name = "sale.order";
|
||||
|
||||
name = fields.Char({ string: "name" });
|
||||
partner_id = fields.Many2one({ string: "Customer", relation: "res.partner" });
|
||||
project_id = fields.Many2one({ string: "Project", relation: "project.project" });
|
||||
order_line = fields.One2many({ relation: "sale.order.line" });
|
||||
|
||||
_records = [{ id: 1, name: "Sales Order 1" }];
|
||||
}
|
||||
|
||||
export class SaleOrderLine extends models.Model {
|
||||
_name = "sale.order.line";
|
||||
|
||||
name = fields.Char({ related: "product_id.name" });
|
||||
product_id = fields.Many2one({ string: "Product", relation: "product.product" });
|
||||
|
||||
_records = [{ id: 1, product_id: 1 }];
|
||||
}
|
||||
|
||||
export class ProductProduct extends models.Model {
|
||||
_name = "product.product";
|
||||
|
||||
name = fields.Char();
|
||||
type = fields.Selection({
|
||||
string: "Type",
|
||||
selection: [("consu", "Goods"), ("service", "Service"), ("combo", "Combo")],
|
||||
});
|
||||
|
||||
_records = [
|
||||
{ id: 1, name: "Service Product 1", type: "service" },
|
||||
{ id: 2, name: "Consumable Product 1", type: "consu" },
|
||||
{ id: 3, name: "Service Product 2", type: "service" },
|
||||
];
|
||||
}
|
||||
|
||||
Object.assign(projectModels, {
|
||||
ProjectMilestone,
|
||||
ProjectTask,
|
||||
});
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { click, edit, queryOne, runAllTimers } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
|
||||
import { focus, mailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { projectModels } from "@project/../tests/project_models";
|
||||
import { contains, defineModels, mountView, onRpc } from "@web/../tests/web_test_helpers";
|
||||
import { ProductProduct, ProjectTask, SaleOrder, SaleOrderLine } from "./project_task_model";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
SaleOrder._views = {
|
||||
form: `
|
||||
<form>
|
||||
<group>
|
||||
<field name="partner_id" required="True"/>
|
||||
<field name="project_id"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Order Lines" name="order_lines">
|
||||
<field name="order_line">
|
||||
<list editable="bottom">
|
||||
<field name="product_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</form>
|
||||
`,
|
||||
};
|
||||
|
||||
ProjectTask._views = {
|
||||
form: `
|
||||
<form>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="project_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field
|
||||
name="sale_line_id"
|
||||
context="{'default_project_id': project_id, 'default_partner_id': partner_id}"
|
||||
widget="so_line_create_button"
|
||||
/>
|
||||
</group>
|
||||
</form>
|
||||
`,
|
||||
};
|
||||
|
||||
projectModels.ProjectTask = ProjectTask;
|
||||
|
||||
defineModels({ ...mailModels, ...projectModels, SaleOrder, SaleOrderLine, ProductProduct });
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectTask._records[0].partner_id = 1;
|
||||
});
|
||||
|
||||
onRpc("get_first_service_line", function ({ args, model }) {
|
||||
const [solId] = this.env[model].browse(args[0])[0].order_line;
|
||||
const productId = this.env["sale.order.line"].browse(solId)[0].product_id;
|
||||
const productType = this.env["product.product"].browse(productId)[0].type;
|
||||
if (productType === "service") {
|
||||
expect.step("valid_so");
|
||||
return [solId];
|
||||
} else {
|
||||
expect.step("invalid_so");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
test("test so_line_create_button widget: valid SO", async () => {
|
||||
const project_name = projectModels.ProjectProject._records.find(
|
||||
(project) => project.id === 1
|
||||
).name;
|
||||
const partner_name = mailModels.ResPartner._records.find((partner) => partner.id === 1).name;
|
||||
await mountView({
|
||||
resId: 1,
|
||||
resModel: "project.task",
|
||||
type: "form",
|
||||
});
|
||||
|
||||
await focus("div[name='sale_line_id'] input");
|
||||
const create_so_button = queryOne(
|
||||
"div[name='sale_line_id'] a[aria-label='Create Sales Order']"
|
||||
);
|
||||
expect(create_so_button).toBeVisible({
|
||||
message: "The so_line_create_button widget should appear when creating a new record.",
|
||||
});
|
||||
await create_so_button.click();
|
||||
await animationFrame();
|
||||
|
||||
expect("div[name='partner_id'] input").toHaveValue(partner_name, {
|
||||
message:
|
||||
"The default_partner_id set in the field context should be passed in the SO form view.",
|
||||
});
|
||||
expect("div[name='project_id'] input").toHaveValue(project_name, {
|
||||
message:
|
||||
"The default_project_id set in the field context should be passed in the SO form view.",
|
||||
});
|
||||
|
||||
await contains(".modal-content .o_field_x2many_list_row_add a").click();
|
||||
await contains(".modal-content .o_selected_row td[name='product_id'] input").edit(
|
||||
"Service Product 2"
|
||||
);
|
||||
await contains(".modal-content .ui-sortable .o-autocomplete--input").click();
|
||||
await contains(".dropdown-item:nth-child(1)").click();
|
||||
await contains(".modal-content button[class*='o_form_button_save']").click();
|
||||
|
||||
expect("div[name='sale_line_id'] input").toHaveValue("Service Product 2", {
|
||||
message: "The sale order line should be created and set in the input field.",
|
||||
});
|
||||
// As the SO contains at least one service product, it should be validated and created.
|
||||
expect.verifySteps(["valid_so"]);
|
||||
});
|
||||
|
||||
test("test so_line_create_button widget: invalid SO", async () => {
|
||||
await mountView({
|
||||
resId: 1,
|
||||
resModel: "project.task",
|
||||
type: "form",
|
||||
});
|
||||
|
||||
await focus("div[name='sale_line_id'] input");
|
||||
await contains("a[aria-label='Create Sales Order']").click();
|
||||
await animationFrame();
|
||||
|
||||
await contains(".modal-content .o_field_x2many_list_row_add a").click();
|
||||
await contains(".modal-content .o_selected_row td[name='product_id'] input").edit(
|
||||
"Consumable Product 1"
|
||||
);
|
||||
await contains(".modal-content .ui-sortable .o-autocomplete--input").click();
|
||||
await contains(".dropdown-item:nth-child(1)").click();
|
||||
await contains(".modal-content button[class*='o_form_button_save']").click();
|
||||
|
||||
expect("div[name='sale_line_id'] input").toHaveValue("", {
|
||||
message: "The sale order line should not be created and set in the input field.",
|
||||
});
|
||||
// As the SO does not contain at least one service product, it should not be validated and created.
|
||||
expect.verifySteps(["invalid_so"]);
|
||||
});
|
||||
|
||||
test("test so_line_create_button widget: visibility conditions", async () => {
|
||||
ProjectTask._records.find((t) => t.id).sale_line_id = SaleOrderLine._records[0].id;
|
||||
await mountView({
|
||||
resId: 1,
|
||||
resModel: "project.task",
|
||||
type: "form",
|
||||
});
|
||||
|
||||
await click("div[name='sale_line_id'] input");
|
||||
expect("div[name='sale_line_id'] a[aria-label='Create Sales Order']").toHaveCount(0, {
|
||||
message:
|
||||
"The so_line_create_button widget should not appear as there is already a value in sale_line_id field.",
|
||||
});
|
||||
await edit("");
|
||||
await runAllTimers();
|
||||
await click("div[name='name'] input");
|
||||
await animationFrame();
|
||||
await focus("div[name='sale_line_id'] input");
|
||||
expect("div[name='sale_line_id'] a[aria-label='Create Sales Order']").toBeVisible({
|
||||
message:
|
||||
"The so_line_create_button widget should appear as there is no value in sale_line_id field.",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
registry.category("web_tour.tours").add('project_create_sol_tour', {
|
||||
url: "/odoo",
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(), {
|
||||
trigger: ".o_app[data-menu-xmlid='project.menu_main_pm']",
|
||||
content: 'Select Project main menu.',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".o_kanban_record:contains(Test History Project):first",
|
||||
content: "Open the project dropdown of project name 'Test History Project'.",
|
||||
run: "hover && click .o_kanban_record:contains(Test History Project):first .o_dropdown_kanban .dropdown-toggle",
|
||||
}, {
|
||||
trigger: ".o_kanban_card_manage_settings a:contains('Settings')",
|
||||
content: 'Start editing the project.',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name='partner_id'] input",
|
||||
content: "Add the customer for this project",
|
||||
run: "click",
|
||||
}, {
|
||||
isActive: ["auto"],
|
||||
trigger: ".ui-autocomplete > li > a:not(:has(i.fa))",
|
||||
content: "Select the customer in the autocomplete dropdown",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: 'div.o_notebook_headers',
|
||||
},
|
||||
{
|
||||
trigger: 'a.nav-link[name="settings"]',
|
||||
content: 'Click on Settings tab to configure this project.',
|
||||
run: "click",
|
||||
}, {
|
||||
id: "project_sale_timesheet_start",
|
||||
trigger: "div[name='sale_line_id'] input",
|
||||
content: 'Add the Sales Order Item',
|
||||
run: "fill New Sale order line",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=sale_line_id] .o-autocomplete--dropdown-menu .o_m2o_dropdown_option_create a",
|
||||
content: "Create an Sales Order Item in the autocomplete dropdown.",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: "body:not(:has(.modal))",
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_button_save:enabled",
|
||||
content: "Save project",
|
||||
run: "click",
|
||||
},
|
||||
// Those steps are currently needed in order to prevent the following issue:
|
||||
// "Form views in edition mode are automatically saved when the page is closed, which leads to stray network requests and inconsistencies."
|
||||
...stepUtils.toggleHomeMenu(),
|
||||
...stepUtils.goToAppSteps("project.menu_main_pm", 'Go to the Project app.'),
|
||||
]});
|
||||
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Add custom step to check allow_billable during project creation
|
||||
* to be able to set a partner on project/tasks.
|
||||
*/
|
||||
import { registry } from "@web/core/registry";
|
||||
import "@project/../tests/tours/project_tour";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(registry.category("web_tour.tours").get("project_test_tour"), {
|
||||
steps() {
|
||||
const originalSteps = super.steps();
|
||||
const projectCreationStepIndex = originalSteps.findIndex(
|
||||
(step) => step.id === "project_creation"
|
||||
);
|
||||
originalSteps.splice(projectCreationStepIndex, 0, {
|
||||
trigger: "div[name='allow_billable'] input",
|
||||
run: "edit Test",
|
||||
});
|
||||
|
||||
return originalSteps;
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue