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

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M0 12h50v26a4 4 0 0 1-4 4H0V12Z" fill="#985184"/><path d="M4 21a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4h46v9a4 4 0 0 1-4 4H4Z" fill="#1AD3BB"/><path d="M0 16h50v4a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4v-4Z" fill="#005E7A"/></svg>

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,23 @@
import { monetaryField, MonetaryField } from "@web/views/fields/monetary/monetary_field";
import { registry } from "@web/core/registry";
import { floatIsZero } from "@web/core/utils/numbers";
export class MonetaryFieldNoZero extends MonetaryField {
static props = {
...MonetaryField.props,
};
/** Override **/
get value() {
const originalValue = super.value;
const decimals = this.currencyDigits ? this.currencyDigits[1] : 2;
return floatIsZero(originalValue, decimals) ? false : originalValue;
}
}
export const monetaryFieldNoZero = {
...monetaryField,
component: MonetaryFieldNoZero,
};
registry.category("fields").add("monetary_no_zero", monetaryFieldNoZero);

View File

@@ -0,0 +1,27 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Component } from "@odoo/owl";
class OpenMatchLineWidget extends Component {
static template = "purchase.OpenMatchLineWidget";
static props = { ...standardFieldProps };
setup() {
super.setup();
this.action = useService("action");
}
async openMatchLine() {
this.action.doActionButton({
type: "object",
resId: this.props.record.resId,
name: "action_open_line",
resModel: "purchase.bill.line.match",
});
}
}
registry.category("fields").add("open_match_line_widget", {
component: OpenMatchLineWidget,
});

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="purchase.OpenMatchLineWidget">
<div t-out="props.record.data[props.name]" t-on-click.prevent.stop="openMatchLine"/>
</t>
</templates>

View File

@@ -0,0 +1,90 @@
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { FileUploader } from "@web/views/fields/file_handler";
import { WarningDialog } from "@web/core/errors/error_dialogs";
import { _t } from "@web/core/l10n/translation";
import { Component } from "@odoo/owl";
export class PurchaseFileUploader extends Component {
static template = "purchase.DocumentFileUploader";
static props = {
...standardWidgetProps,
record: { type: Object, optional: true },
list: { type: Object, optional: true },
};
static components = { FileUploader };
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.dialog = useService("dialog");
this.attachmentIdsToProcess = [];
}
get resModel() {
return "purchase.order";
}
get records() {
return this.props.record ? [this.props.record] : this.props.list.records;
}
async getIds() {
if (this.props.record) {
return this.props.record.data.id;
}
return this.props.list.getResIds(true);
}
onClick(ev) {
if (this.env.config.viewType !== "list") {
return;
}
const vendorSet = new Set(this.props.list.selection.map((record) => record.data.partner_id.id));
if (vendorSet.size > 1) {
this.dialog.add(WarningDialog, {
title: _t("Validation Error"),
message: _t("You can only upload a bill for a single vendor at a time."),
});
return false;
}
}
async onFileUploaded(file) {
const att_data = {
name: file.name,
mimetype: file.type,
datas: file.data,
};
const [att_id] = await this.orm.create("ir.attachment", [att_data], {
context: { ...this.env.searchModel.context },
});
this.attachmentIdsToProcess.push(att_id);
}
async onUploadComplete() {
const resModel = this.resModel;
const ids = await this.getIds();
let action;
try {
action = await this.orm.call(
resModel,
"action_create_invoice",
[ids, this.attachmentIdsToProcess],
{ context: { ...this.env.searchModel.context } }
);
} finally {
// ensures attachments are cleared on success as well as on error
this.attachmentIdsToProcess = [];
}
this.action.doAction(action);
}
}
export const purchaseFileUploader = {
component: PurchaseFileUploader,
};
registry.category("view_widgets").add("purchase_file_uploader", purchaseFileUploader);

View File

@@ -0,0 +1,18 @@
<templates>
<t t-name="purchase.DocumentFileUploader">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
fileUploadClass="'document_file_uploader'"
multiUpload="true"
onClick.bind="onClick"
onUploaded.bind="onFileUploaded"
onUploadComplete.bind="onUploadComplete">
<t t-set-slot="toggler">
<button type="button" class="btn btn-secondary" data-hotkey="shift+i">
Upload Bill
</button>
</t>
<t t-slot="default"/>
</FileUploader>
</t>
</templates>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-inherit="account.TaxTotalsField" t-inherit-mode="extension" owl="1">
<xpath expr="//tr[2]" position="after">
<tr t-if="totals.amount_total_cc">
<td colspan="2">
<span t-out="totals.amount_total_cc" style="font-size: 1.1em;"/>
</td>
</tr>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,19 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5901 20.9763V55.6806C15.5901 59.2894 15.3694 58.7419 18.3453 60.3903C19.3255 60.9319 19.7052 61.188 20.2056 61.4412C20.6282 61.6371 21.0912 61.7299 21.5567 61.712C19.9642 61.503 19.7111 60.5875 19.7111 60.5875V23.4989C19.7111 22.0978 19.7111 21.1176 20.3969 20.2375L16.4614 17.9062C15.8887 18.8275 15.5867 19.8914 15.5901 20.9763Z" fill="#FBDBD0"/>
<path d="M44.1896 2.73225C42.7502 1.90217 41.9554 2.25246 39.6153 3.59472C31.7413 8.10716 20.8856 14.3651 19.3049 15.2747C18.1531 15.8969 17.1763 16.7988 16.4644 17.8974L20.3881 20.2198C20.8143 19.7122 21.3351 19.2923 21.9217 18.9836L45.523 5.4168C46.9948 4.56613 48.022 4.79278 48.337 6.00551C48.337 4.63088 45.8644 3.69774 44.1896 2.73225Z" fill="white"/>
<path d="M45.1904 48.3924C44.4251 48.8339 29.2158 57.6057 22.5899 61.3881C21.3477 62.0975 19.5669 61.4912 19.5669 60.5433V23.4341C19.5669 21.356 19.5669 20.1962 21.7922 18.9276L45.4877 5.30487C47.2833 4.27168 48.4136 4.82508 48.4136 6.80903V43.0146C48.4136 46.1818 46.1736 47.8243 45.1904 48.3924Z" fill="white"/>
<path d="M45.9057 21.2854L36.6718 26.7397C36.4694 26.8587 36.3451 27.076 36.3451 27.3108V36.3239C36.3416 36.4279 36.4232 36.515 36.5272 36.5184C36.5671 36.5197 36.6064 36.5083 36.6394 36.4858L45.3494 31.3199C45.7872 31.0616 46.0567 30.5919 46.0588 30.0836V21.3737C46.0607 21.3184 46.0174 21.2721 45.9622 21.2702C45.9423 21.2695 45.9226 21.2748 45.9057 21.2854Z" fill="#C1DBF6"/>
<path d="M33.5428 28.2763L24.3089 33.7307C24.1065 33.8482 23.982 34.0647 23.9822 34.2988V43.306C23.9787 43.41 24.0602 43.4971 24.1642 43.5005C24.2042 43.5018 24.2435 43.4904 24.2765 43.4679L32.9982 38.3167C33.4374 38.0569 33.707 37.5848 33.7076 37.0745V28.3763C33.7226 28.3231 33.6916 28.2679 33.6384 28.2529C33.6047 28.2434 33.5684 28.2523 33.5428 28.2763Z" fill="#C1DBF6"/>
<path d="M45.9057 34.0809L36.6718 39.5353C36.4694 39.6529 36.3449 39.8693 36.3451 40.1034V49.1106C36.3416 49.2146 36.4232 49.3017 36.5272 49.3051C36.5671 49.3064 36.6064 49.295 36.6394 49.2725L45.3611 44.1213C45.8003 43.8615 46.0699 43.3894 46.0705 42.8792V34.1663C46.0757 34.1113 46.0354 34.0624 45.9803 34.0572C45.9533 34.0546 45.9263 34.0632 45.9057 34.0809Z" fill="#C1DBF6"/>
<path d="M33.5428 41.0689L24.3089 46.5232C24.1065 46.6422 23.9822 46.8595 23.9822 47.0943V56.1015C23.9787 56.2055 24.0602 56.2926 24.1642 56.296C24.2042 56.2973 24.2435 56.2859 24.2765 56.2634L32.9982 51.1122C33.4342 50.848 33.6992 50.374 33.6959 49.8641V41.1513C33.6917 41.0945 33.6424 41.0519 33.5856 41.056C33.5706 41.0571 33.5559 41.0615 33.5428 41.0689Z" fill="#C1DBF6"/>
<path d="M41.8759 12.9581L40.2894 13.8912C40.2163 13.9326 40.1713 14.0103 40.1716 14.0943V16.7435C40.1711 16.8735 40.2762 16.9794 40.4062 16.9799C40.449 16.98 40.4911 16.9685 40.5278 16.9466L42.1144 16.0135C42.1874 15.972 42.2324 15.8944 42.2321 15.8104V13.1612C42.2326 13.0311 42.1276 12.9253 41.9975 12.9248C41.9547 12.9246 41.9127 12.9361 41.8759 12.9581ZM41.5404 15.8311L40.7781 16.2961C40.7427 16.3197 40.6574 16.3226 40.6574 16.249V14.3769C40.6574 14.3034 40.7251 14.268 40.7751 14.2386L41.508 13.7882C41.5905 13.7441 41.667 13.7558 41.667 13.8383V15.6397C41.667 15.7339 41.6788 15.7604 41.5404 15.8311Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8467 33.3942C28.973 33.3942 29.0753 33.4965 29.0753 33.6227V38.6925C29.0753 38.8188 28.973 38.9211 28.8467 38.9211C28.7205 38.9211 28.6181 38.8188 28.6181 38.6925V33.6227C28.6181 33.4965 28.7205 33.3942 28.8467 33.3942Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.8348 35.2242C30.8949 35.3351 30.8537 35.4739 30.7427 35.534L27.0325 37.5453C26.9215 37.6054 26.7828 37.5642 26.7226 37.4532C26.6625 37.3423 26.7037 37.2035 26.8146 37.1434L30.5249 35.1321C30.6359 35.072 30.7746 35.1132 30.8348 35.2242Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.2921 48.1447C27.3615 48.0393 27.5033 48.0101 27.6087 48.0795L30.3205 49.8647C30.4259 49.9341 30.4551 50.0759 30.3857 50.1813C30.3163 50.2867 30.1745 50.3159 30.0691 50.2465L27.3574 48.4613C27.2519 48.3919 27.2227 48.2501 27.2921 48.1447Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.6547 46.1501C30.7649 46.2116 30.8044 46.3509 30.7428 46.4611L27.6727 51.9596C27.6111 52.0698 27.4719 52.1093 27.3617 52.0477C27.2515 51.9862 27.212 51.8469 27.2735 51.7367L30.3437 46.2382C30.4052 46.128 30.5445 46.0885 30.6547 46.1501Z" fill="#374874"/>
<path d="M40.6867 40.2152C40.7089 39.7601 40.9432 39.3417 41.3196 39.0849C41.667 38.8789 41.9495 39.0496 41.9495 39.4647C41.9262 39.9195 41.6936 40.338 41.3196 40.5979C40.9693 40.7922 40.6867 40.6215 40.6867 40.2035V40.2152ZM41.9377 43.2324C41.9135 43.6862 41.6811 44.1034 41.3078 44.3627C40.9575 44.5687 40.675 44.398 40.675 43.9829C40.6953 43.5273 40.93 43.1081 41.3078 42.8526C41.6669 42.6437 41.9377 42.8144 41.9377 43.2324Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.7104 40.2872C43.775 40.3957 43.7394 40.536 43.631 40.6006L39.2156 43.2292C39.1072 43.2937 38.9669 43.2581 38.9023 43.1497C38.8377 43.0412 38.8733 42.9009 38.9818 42.8364L43.3971 40.2078C43.5056 40.1432 43.6459 40.1788 43.7104 40.2872Z" fill="#374874"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.7104 27.5647C43.775 27.6732 43.7394 27.8134 43.631 27.878L39.2156 30.5066C39.1072 30.5712 38.9669 30.5356 38.9023 30.4271C38.8377 30.3186 38.8733 30.1784 38.9818 30.1138L43.3971 27.4852C43.5056 27.4206 43.6459 27.4562 43.7104 27.5647Z" fill="#374874"/>
<path d="M42.7756 2.28587C43.2317 2.28587 43.6684 2.43167 44.1895 2.73224C45.8644 3.69771 48.337 4.63085 48.337 6.00546C48.3331 5.9903 48.3277 5.97716 48.3235 5.96232C48.3809 6.20797 48.4135 6.48765 48.4135 6.80907V43.0146C48.4135 46.1819 46.1735 47.8243 45.1903 48.3924C44.425 48.834 29.2158 57.6057 22.5899 61.3882C22.2348 61.5909 21.8361 61.6832 21.4471 61.6937C21.4841 61.6996 21.5182 61.7069 21.5567 61.7119C21.5189 61.7134 21.4812 61.7141 21.4434 61.7141C21.0163 61.7141 20.5939 61.6212 20.2056 61.4412C19.7052 61.188 19.3254 60.9319 18.3452 60.3903C15.3693 58.742 15.5901 59.2894 15.5901 55.6806V20.9763C15.5867 19.8915 15.8886 18.8275 16.4614 17.9062L16.4643 17.8973C17.1763 16.7988 18.153 15.8969 19.3048 15.2747C20.8855 14.3651 31.7413 8.10715 39.6153 3.59469C41.1081 2.73842 41.972 2.28584 42.7756 2.28587ZM42.7756 1.37158C41.7078 1.37154 40.7062 1.91494 39.1604 2.80159C32.6208 6.54934 24.0517 11.4854 20.3925 13.5931L18.8589 14.4764C17.5809 15.1693 16.4877 16.1801 15.6971 17.4001L15.6336 17.498L15.6266 17.519C15.0004 18.5647 14.672 19.7586 14.6758 20.9791V55.6806C14.6758 55.9304 14.6747 56.1601 14.6737 56.3719C14.6672 57.7864 14.6639 58.4964 15.0132 59.1456C15.3827 59.8324 16.0172 60.173 17.0685 60.7372C17.3135 60.8687 17.59 61.0172 17.9022 61.1901C18.3541 61.4398 18.6806 61.6303 18.9429 61.7834C19.2558 61.9661 19.503 62.1103 19.7927 62.257L19.8068 62.2641L19.821 62.2706C20.3259 62.5047 20.8869 62.6284 21.4434 62.6284C21.4929 62.6284 21.5423 62.6274 21.5918 62.6255L21.5929 62.6024C22.1164 62.5707 22.6155 62.4264 23.0432 62.1821C29.8225 58.312 45.4919 49.2739 45.6471 49.1844C46.7542 48.5447 49.3277 46.6636 49.3277 43.0146V6.80904C49.3277 6.49842 49.3015 6.20214 49.2497 5.92582C49.1929 4.32884 47.4271 3.4082 45.7186 2.51736C45.3356 2.31771 44.9738 2.12909 44.646 1.94013C43.9643 1.54696 43.3874 1.3716 42.7756 1.37158Z" fill="#374874"/>
<path d="M44.5869 10.5179V15.3512C44.5865 15.4858 44.5148 15.6101 44.3985 15.6779L23.3934 27.8406V22.8366C23.3931 22.8073 44.5869 10.5179 44.5869 10.5179ZM44.5869 10.0607C44.5077 10.0607 44.4285 10.0813 44.3576 10.1224C44.3576 10.1224 39.0591 13.1948 33.7605 16.2709C31.1112 17.809 28.4618 19.348 26.4749 20.5043C25.4811 21.0826 24.6529 21.5652 24.0733 21.9042C23.7828 22.074 23.5546 22.2079 23.399 22.2997C23.0694 22.4943 22.933 22.5748 22.9363 22.8423L22.9363 27.8406C22.9363 28.004 23.0235 28.155 23.1651 28.2367C23.2358 28.2774 23.3146 28.2978 23.3934 28.2978C23.4725 28.2978 23.5517 28.2773 23.6225 28.2362L44.6276 16.0735C44.884 15.9239 45.0431 15.648 45.0441 15.3527V10.5178C45.0441 10.3544 44.9568 10.2033 44.8151 10.1217C44.7445 10.081 44.6657 10.0607 44.5869 10.0607Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,34 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class PurchaseDatetimePicker extends Interaction {
static selector = ".o-purchase-datetimepicker";
start() {
this.registerCleanup(
this.services.datetime_picker
.create({
target: this.el,
onChange: (newDate) => {
const { accessToken, orderId, lineId } = this.el.dataset;
this.waitFor(
rpc(`/my/purchase/${orderId}/update?access_token=${accessToken}`, {
[lineId]: newDate.toISODate(),
})
);
},
pickerProps: {
type: "date",
value: luxon.DateTime.fromISO(this.el.dataset.value),
},
})
.enable()
);
}
}
registry
.category("public.interactions")
.add("purchase.purchase_datetime_picker", PurchaseDatetimePicker);

View File

@@ -0,0 +1,21 @@
import { Sidebar } from "@portal/interactions/sidebar";
import { registry } from "@web/core/registry";
export class PurchaseSidebar extends Sidebar {
static selector = ".o_portal_purchase_sidebar";
setup() {
super.setup();
this.spyWatched = document.querySelector("body[data-target='.navspy']");
}
start() {
super.start();
// Nav Menu ScrollSpy
this.generateMenu({ "max-width": "200px" });
}
}
registry
.category("public.interactions")
.add("purchase.purchase_sidebar", PurchaseSidebar);

View File

@@ -0,0 +1,133 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
import PurchaseAdditionalTourSteps from "@purchase/js/tours/purchase_steps";
registry.category("web_tour.tours").add("purchase_tour", {
url: "/odoo",
steps: () => [
stepUtils.showAppsMenuItem(),
{
isActive: ["community"],
trigger: '.o_app[data-menu-xmlid="purchase.menu_purchase_root"]',
content: _t(
"Let's try the Purchase app to manage the flow from purchase to reception and invoice control."
),
tooltipPosition: "right",
run: "click",
},
{
isActive: ["enterprise"],
trigger: '.o_app[data-menu-xmlid="purchase.menu_purchase_root"]',
content: _t(
"Let's try the Purchase app to manage the flow from purchase to reception and invoice control."
),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_purchase_order",
},
{
trigger: ".o_list_button_add",
content: _t("Let's create your first request for quotation."),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_purchase_order",
},
{
trigger: ".o_field_res_partner_many2one[name='partner_id'] input",
content: _t("Search a vendor name, or create one on the fly."),
tooltipPosition: "bottom",
async run(actions) {
const input = this.anchor.querySelector("input");
await actions.edit("Azure Interior", input || this.anchor);
},
},
{
isActive: ["auto"],
trigger: ".ui-menu-item > a:contains('Azure Interior')",
run: "click",
},
{
trigger: ".o_field_res_partner_many2one[name='partner_id'] .o_external_button",
},
{
trigger: ".o_field_x2many_list_row_add > a",
content: _t("Add some products or services to your quotation."),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_purchase_order",
},
{
trigger: ".o_field_widget[name=product_id], .o_field_widget[name=product_template_id]",
content: _t("Select a product, or create a new one on the fly."),
tooltipPosition: "right",
async run(actions) {
const input = this.anchor.querySelector("input");
await actions.edit("DESK0001", input || this.anchor);
},
},
{
isActive: ["auto"],
trigger: "a:contains('DESK0001')",
run: "click",
},
{
trigger: ".o_field_text[name='name'] textarea:value(DESK0001)",
},
{
trigger: ".o_purchase_order",
},
{
trigger: "div.o_field_widget[name='product_qty'] input ",
content: _t("Indicate the product quantity you want to order."),
tooltipPosition: "right",
run: "edit 12.0",
},
{
isActive: ["auto", "mobile"],
trigger: ".o_statusbar_buttons .o_arrow_button_current[name='action_rfq_send']",
},
...stepUtils.statusbarButtonsSteps(
"Send by Email",
_t("Send the request for quotation to your vendor.")
),
{
trigger: ".modal-footer button[name='action_send_mail']",
},
{
trigger: ".modal-footer button[name='action_send_mail']",
content: _t("Send the request for quotation to your vendor."),
tooltipPosition: "left",
run: "click",
},
{
trigger: ".o_purchase_order",
},
{
content: "Select price",
trigger: 'tbody tr.o_data_row .o_list_number[name="price_unit"]',
},
{
trigger: "tbody tr.o_data_row .o_list_number[name='price_unit']",
content: _t(
"Once you get the price from the vendor, you can complete the purchase order with the right price."
),
tooltipPosition: "right",
run: "edit 200.00",
},
{
isActive: ["auto"],
trigger: ".o_purchase_order",
run: "click",
},
...stepUtils.statusbarButtonsSteps("Confirm Order", _t("Confirm your purchase.")),
...new PurchaseAdditionalTourSteps()._get_purchase_stock_steps(),
],
});

View File

@@ -0,0 +1,14 @@
class PurchaseAdditionalTourSteps {
_get_purchase_stock_steps() {
return [
{
// Useless final step to trigger congratulation message
isActive: ["auto"],
trigger: ".o_purchase_order",
run: "click",
},
];
}
}
export default PurchaseAdditionalTourSteps;

View File

@@ -0,0 +1,18 @@
import { ProductCatalogKanbanRecord } from "@product/product_catalog/kanban_record";
import { ProductCatalogPurchaseOrderLine } from "./purchase_order_line/purchase_order_line";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
patch(ProductCatalogKanbanRecord.prototype, {
setup() {
super.setup();
this.orm = useService("orm");
},
get orderLineComponent() {
if (this.env.orderResModel === "purchase.order") {
return ProductCatalogPurchaseOrderLine;
}
return super.orderLineComponent;
},
});

View File

@@ -0,0 +1,40 @@
import { useService } from "@web/core/utils/hooks";
import { ProductCatalogKanbanRenderer } from "@product/product_catalog/kanban_renderer";
export class PurchaseProductCatalogKanbanRenderer extends ProductCatalogKanbanRenderer {
static template = "PurchaseProductCatalogKanbanRenderer";
setup() {
super.setup();
this.action = useService("action");
}
get createProductContext() {
return {default_seller_ids: [{partner_id:this.props.list._config.context.partner_id}],};
}
async createProduct() {
await this.action.doAction(
{
type: "ir.actions.act_window",
res_model: "product.product",
target: "new",
views: [[false, "form"]],
view_mode: "form",
context: this.createProductContext,
},
{
props: {
onSave: async (record, params) => {
await this.props.list.model.load();
this.props.list.model.useSampleModel = false;
this.action.doAction({
type: "ir.actions.act_window_close",
});
},
}
}
);
}
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="PurchaseProductCatalogKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
<t t-if="showNoContentHelper" position="replace">
<t t-if="showNoContentHelper">
<div class="o_view_nocontent" role="alert">
<div class="o_nocontent_help">
<p class="o_view_nocontent_smiling_face">
No Products for the selected Vendor.
</p>
<p>
Try removing the top filter to broaden your search
<br/>
<button class="mt-2 btn btn-primary" t-on-click="this.createProduct">Create a product</button>
</p>
</div>
</div>
</t>
</t>
</t>
</templates>

View File

@@ -0,0 +1,11 @@
import { registry } from "@web/core/registry";
import { productCatalogKanbanView } from "@product/product_catalog/kanban_view";
import { PurchaseProductCatalogKanbanRenderer } from "./kanban_renderer.js";
export const purchaseProductCatalogKanbanView = {
...productCatalogKanbanView,
Renderer: PurchaseProductCatalogKanbanRenderer,
};
registry.category("views").add("purchase_product_kanban_catalog", purchaseProductCatalogKanbanView);

View File

@@ -0,0 +1,13 @@
import { ProductCatalogOrderLine } from "@product/product_catalog/order_line/order_line";
export class ProductCatalogPurchaseOrderLine extends ProductCatalogOrderLine {
static props = {
...ProductCatalogPurchaseOrderLine.props,
min_qty: { type: Number, optional: true },
packaging: { type: Object, optional: true },
};
get highlightUoM() {
return true;
}
}

View File

@@ -0,0 +1,8 @@
// Limit product image size
.o_purchase_portal_product_image {
width: 48px;
height: 48px;
object-fit: contain;
margin-right: 3px;
margin-left: 3px;
}

View File

@@ -0,0 +1,36 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
class ButtonWithNotification extends Component {
static template = "purchase.ButtonWithNotification";
static props = {
...standardWidgetProps,
method: String,
title: String,
};
setup() {
this.orm = useService("orm");
this.notification = useService("notification");
}
async onClick() {
const result = await this.orm.call(this.props.record.resModel, this.props.method, [
this.props.record.resId,
]);
const message = result.toast_message;
this.notification.add(message, { type: "success" });
}
}
export const buttonWithNotification = {
component: ButtonWithNotification,
extractProps: ({ attrs }) => {
return {
method: attrs.button_name,
title: attrs.title,
};
},
};
registry.category("view_widgets").add("toaster_button", buttonWithNotification);

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<template>
<t t-name="purchase.ButtonWithNotification">
<button t-on-click="onClick" t-att-name="props.method" type="button" class="btn oe_inline btn-link" t-att-title="props.title">
<i class="fa fa-fw o_button_icon fa-info-circle"></i>
</button>
</t>
</template>

View File

@@ -0,0 +1,38 @@
import { useService } from "@web/core/utils/hooks";
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
export class PurchaseDashBoard extends Component {
static template = "purchase.PurchaseDashboard";
static props = { list: { type: Object, optional: true } };
setup() {
this.orm = useService("orm");
this.action = useService("action");
onWillStart(async () => {
await this.updateDashboardState();
});
onWillUpdateProps(async () => {
await this.updateDashboardState();
});
}
async updateDashboardState() {
this.purchaseData = await this.orm.call("purchase.order", "retrieve_dashboard");
this.multiuser = JSON.stringify(this.purchaseData.global) !== JSON.stringify(this.purchaseData.my);
}
/**
* This method clears the current search query and activates
* the filters found in `filter_name` attibute from button pressed
*/
setSearchContext(ev) {
const filter_name = ev.currentTarget.getAttribute("filter_name");
const filters = filter_name.split(",");
const searchItems = this.env.searchModel.getSearchItems((item) =>
filters.includes(item.name)
);
this.env.searchModel.query = [];
for (const item of searchItems) {
this.env.searchModel.toggleSearchItem(item.id);
}
}
}

View File

@@ -0,0 +1,42 @@
.o_purchase_dashboard {
.purchase-dashboard-card {
@include media-breakpoint-up(lg) {
&.o_purchase_dashboard_card_top {
--btn-border-radius: #{$btn-border-radius} #{$btn-border-radius} 0 0;
}
&.o_purchase_dashboard_card_bottom {
--btn-border-radius: 0 0 #{$btn-border-radius} #{$btn-border-radius};
}
&.o_purchase_dashboard_card_sole {
--btn-border-radius: #{$btn-border-radius};
}
}
width: 100%;
height: 100%;
&:where(:not(.o_no_hover):hover) {
text-decoration: underline;
}
&:active {
border: none;
}
}
.o_no_hover {
pointer-events: none;
}
.o_priority_star {
color: yellow;
}
}
.o_purchase_dashboard_list_view .o_list_renderer {
@include media-breakpoint-up(md) {
height: auto;
}
}

View File

@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="purchase.PurchaseDashboard">
<div class="o_purchase_dashboard container-fluid py-4 border-bottom bg-view">
<div class="row justify-content-between gap-2 gap-lg-0">
<div class="col-12 col-lg-8 flex-grow-1 flex-lg-grow-0 flex-shrink-0">
<div class="grid gap-1 gap-lg-4">
<div class="g-col-12 g-col-lg-1 d-flex align-items-center pt-2 pb-lg-2 justify-content-lg-end text-break">
<t t-if="multiuser">All</t>
</div>
<div class="g-col-12 g-col-lg-11 grid gap-1" style="--columns: 61">
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="All Draft RFQs" filter_name="draft_rfqs">
<a href="#" class="btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="o_purchase_dashboard_card_{{ multiuser ? 'top' : 'sole' }}
{{ purchaseData['global']['draft']['all'] == 0
? 'bg-secondary-subtle text-secondary-emphasis'
: 'bg-info-subtle text-info-emphasis' }}">
<div class="fs-2">
<span t-out="purchaseData['global']['draft']['all']"/>
<span class="ps-4" t-if="purchaseData['global']['draft']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['global']['draft']['priority']"/>
</span>
</div>New
</a>
</div>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="All Sent RFQs" filter_name="waiting_rfqs">
<a href="#" class="btn purchase-dashboard-card p-1 p-lg-2 bg-secondary-subtle text-secondary-emphasis text-truncate text-wrap"
t-attf-class="o_purchase_dashboard_card_{{ multiuser ? 'top' : 'sole' }}">
<div class="fs-2">
<span t-out="purchaseData['global']['sent']['all']"/>
<span class="ps-4" t-if="purchaseData['global']['sent']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['global']['sent']['priority']"/>
</span>
</div>RFQ Sent
</a>
</div>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="All Late RFQs" filter_name="late">
<a href="#" class="btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="o_purchase_dashboard_card_{{ multiuser ? 'top' : 'sole' }}
{{ purchaseData['global']['late']['all'] == 0
? 'bg-secondary-subtle text-secondary-emphasis'
: 'bg-warning-subtle text-warning-emphasis' }}">
<div class="fs-2">
<span t-out="purchaseData['global']['late']['all']"/>
<span class="ps-4" t-if="purchaseData['global']['late']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['global']['late']['priority']"/>
</span>
</div>Late RFQ
</a>
</div>
<div class="g-col-1"/>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="All Not Acknowledged POs" filter_name="not_acknowledged">
<a href="#" class="btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="o_purchase_dashboard_card_{{ multiuser ? 'top' : 'sole' }}
{{ purchaseData['global']['not_acknowledged']['all'] == 0
? 'bg-secondary-subtle text-secondary-emphasis'
: 'bg-info-subtle text-info-emphasis' }}">
<div class="fs-2">
<span t-out="purchaseData['global']['not_acknowledged']['all']"/>
<span class="ps-4" t-if="purchaseData['global']['not_acknowledged']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['global']['not_acknowledged']['priority']"/>
</span>
</div>Not Acknowledged
</a>
</div>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="All Late Receipt POs" filter_name="late_receipt">
<a href="#" class="btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="o_purchase_dashboard_card_{{ multiuser ? 'top' : 'sole' }}
{{ purchaseData['global']['late_receipt']['all'] == 0
? 'bg-secondary-subtle text-secondary-emphasis'
: 'bg-danger-subtle text-danger-emphasis' }}">
<div class="fs-2">
<span t-out="purchaseData['global']['late_receipt']['all']"/>
<span class="ps-4" t-if="purchaseData['global']['late_receipt']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['global']['late_receipt']['priority']"/>
</span>
</div>Late Receipt
</a>
</div>
</div>
</div>
<div t-if="multiuser" class="grid gap-1 gap-lg-4">
<div class="g-col-12 g-col-lg-1 d-flex align-items-center pt-2 pb-lg-2 justify-content-lg-end text-break">
My
</div>
<div class="g-col-12 g-col-lg-11 grid gap-1 pt-0 pt-lg-1" style="--columns: 61">
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="My Draft RFQs" filter_name="draft_rfqs,my_purchases">
<a href="#" class="o_purchase_dashboard_card_bottom btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="{{ purchaseData['my']['draft']['all'] == 0 ? 'bg-secondary-subtle text-secondary-emphasis' : 'bg-info-subtle text-info-emphasis' }}">
<div>
<span t-out="purchaseData['my']['draft']['all']"/>
<span class="ps-4" t-if="purchaseData['my']['draft']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['my']['draft']['priority']"/>
</span>
</div>
</a>
</div>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="My Waiting RFQs" filter_name="waiting_rfqs,my_purchases">
<a href="#" class="o_purchase_dashboard_card_bottom btn purchase-dashboard-card p-1 p-lg-2 bg-secondary-subtle text-secondary-emphasis text-truncate text-wrap">
<div>
<span t-out="purchaseData['my']['sent']['all']"/>
<span class="ps-4" t-if="purchaseData['my']['sent']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['my']['sent']['priority']"/>
</span>
</div>
</a>
</div>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="My Late RFQs" filter_name="late,my_purchases">
<a href="#" class="o_purchase_dashboard_card_bottom btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="{{ purchaseData['my']['late']['all'] == 0 ? 'bg-secondary-subtle text-secondary-emphasis' : 'bg-warning-subtle text-warning-emphasis' }}">
<div>
<span t-out="purchaseData['my']['late']['all']"/>
<span class="ps-4" t-if="purchaseData['my']['late']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['my']['late']['priority']"/>
</span>
</div>
</a>
</div>
<div class="g-col-1"/>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="My Not Acknowledged POs" filter_name="not_acknowledged,my_purchases">
<a href="#" class="o_purchase_dashboard_card_bottom btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="{{ purchaseData['my']['not_acknowledged']['all'] == 0 ? 'bg-secondary-subtle text-secondary-emphasis' : 'bg-info-subtle text-info-emphasis' }}">
<div>
<span t-out="purchaseData['my']['not_acknowledged']['all']"/>
<span class="ps-4" t-if="purchaseData['my']['not_acknowledged']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['my']['not_acknowledged']['priority']"/>
</span>
</div>
</a>
</div>
<div class="g-col-12 p-0" t-on-click="setSearchContext" title="My Late Receipt POs" filter_name="late_receipt,my_purchases">
<a href="#" class="o_purchase_dashboard_card_bottom btn purchase-dashboard-card p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="{{ purchaseData['my']['late_receipt']['all'] == 0 ? 'bg-secondary-subtle text-secondary-emphasis' : 'bg-danger-subtle text-danger-emphasis' }}">
<div>
<span t-out="purchaseData['my']['late_receipt']['all']"/>
<span class="ps-4" t-if="purchaseData['my']['late_receipt']['priority']">
<span class="o_priority_star fa fa-star "/> <span t-out="purchaseData['my']['late_receipt']['priority']"/>
</span>
</div>
</a>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-3 flex-shrink-0">
<div class="grid gap-1 gap-lg-4">
<div class="d-lg-none g-col-12 d-flex align-items-center pt-2 pb-lg-2 justify-content-lg-end text-break">
All
</div>
<div class="g-col-12 g-col-lg-12 grid gap-1">
<div class="d-none d-lg-block g-col-6 p-0" id="left_grid_top"/>
<div class="g-col-6 p-0" title="All Days to Order">
<div class="purchase-dashboard-card o_no_hover p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="o_purchase_dashboard_card_{{ multiuser ? 'top' : 'sole' }}
{{ purchaseData['days_to_purchase'] and
purchaseData['global']['days_to_order'] > purchaseData['days_to_purchase']
? 'btn btn-warning'
: 'bg-100 text-center' }}">
<div class="fs-2" t-out="purchaseData['global']['days_to_order']"/>Days to Order
</div>
</div>
</div>
</div>
<div t-if="multiuser" class="grid gap-1 gap-lg-4 pt-0 pt-lg-1">
<div class="d-lg-none g-col-12 d-flex align-items-center pt-2 pb-lg-2 justify-content-lg-end text-break">
My
</div>
<div class="g-col-12 g-col-lg-12 grid gap-1">
<div class="d-none d-lg-block g-col-6 p-0" id="left_grid_bottom"/>
<div class="g-col-6 p-0" title="My Days to Order">
<div class="o_purchase_dashboard_card_bottom purchase-dashboard-card o_no_hover p-1 p-lg-2 text-truncate text-wrap"
t-attf-class="{{
purchaseData['days_to_purchase'] and
purchaseData['global']['days_to_order'] > purchaseData['days_to_purchase']
? 'btn btn-warning' : 'bg-100 text-center' }}">
<div t-out="purchaseData['my']['days_to_order']"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,16 @@
import { registry } from "@web/core/registry";
import { fileUploadKanbanView } from "@account/views/file_upload_kanban/file_upload_kanban_view";
import { FileUploadKanbanRenderer } from "@account/views/file_upload_kanban/file_upload_kanban_renderer";
import { PurchaseDashBoard } from "@purchase/views/purchase_dashboard";
export class PurchaseDashBoardKanbanRenderer extends FileUploadKanbanRenderer {
static template = "purchase.PurchaseKanbanView";
static components = Object.assign({}, FileUploadKanbanRenderer.components, { PurchaseDashBoard });
}
export const PurchaseDashBoardKanbanView = {
...fileUploadKanbanView,
Renderer: PurchaseDashBoardKanbanRenderer,
};
registry.category("views").add("purchase_dashboard_kanban", PurchaseDashBoardKanbanView);

View File

@@ -0,0 +1,7 @@
<templates>
<t t-name="purchase.PurchaseKanbanView" t-inherit="account.FileUploadKanbanRenderer" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_kanban_renderer')]" position="before">
<PurchaseDashBoard />
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,27 @@
import { registry } from "@web/core/registry";
import { PurchaseDashBoard } from "@purchase/views/purchase_dashboard";
import { PurchaseFileUploader } from "@purchase/components/purchase_file_uploader/purchase_file_uploader";
import { FileUploadListController } from "@account/views/file_upload_list/file_upload_list_controller";
import { FileUploadListRenderer } from "@account/views/file_upload_list/file_upload_list_renderer";
import { fileUploadListView } from "@account/views/file_upload_list/file_upload_list_view";
export class PurchaseDashBoardRenderer extends FileUploadListRenderer {
static template = "purchase.ListRenderer";
static components = Object.assign({}, FileUploadListRenderer.components, { PurchaseDashBoard });
}
export class PurchaseFileUploadListController extends FileUploadListController {
static template = `purchase.ListView`;
static components = {
...FileUploadListController.components,
PurchaseFileUploader,
};
}
export const PurchaseDashBoardListView = {
...fileUploadListView,
Controller: PurchaseFileUploadListController,
Renderer: PurchaseDashBoardRenderer,
};
registry.category("views").add("purchase_dashboard_list", PurchaseDashBoardListView);

View File

@@ -0,0 +1,13 @@
<templates>
<t t-name="purchase.ListRenderer" t-inherit="account.FileUploadListRenderer" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_list_renderer')]" position="before">
<PurchaseDashBoard list="props.list"/>
</xpath>
</t>
<t t-name="purchase.ListView" t-inherit="web.ListView" t-inherit-mode="primary">
<xpath expr="//SelectionBox" position="after">
<PurchaseFileUploader list="model.root"/>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,79 @@
import { addSectionFromProductCatalog } from "@account/js/tours/tour_utils";
import { productCatalog, purchaseForm } from "./tour_helper";
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("test_add_section_from_product_catalog_on_purchase_order", {
steps: () => [
...purchaseForm.createNewPO(),
...purchaseForm.selectVendor("Test Vendor"),
...addSectionFromProductCatalog(),
],
});
registry.category("web_tour.tours").add("test_catalog_vendor_uom", {
steps: () => [
// Open the PO for the vendor selling product as "Units".
{ trigger: "td[data-tooltip='PO/TEST/00002']", run: "click" },
...purchaseForm.displayOptionalField("discount"),
...purchaseForm.openCatalog(),
{
content: "Check 'No section' is selected in the catalog",
trigger: ".o_search_panel_sections .o_selected_section:contains('No Section') span.o_section_name",
run: "click",
},
...productCatalog.checkProductPrice("Crab Juice", "$ 2.50"),
// Add 6 units and check the price is correctly updated.
...productCatalog.addProduct("Crab Juice"),
...productCatalog.checkProductUoM("Crab Juice", "Units"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.waitForQuantity("Crab Juice", 5),
...productCatalog.checkProductUoM("Crab Juice", "Units"),
...productCatalog.checkProductPrice("Crab Juice", "$ 2.50"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.checkProductPrice("Crab Juice", "$ 2.45"),
// Add 6 units more and check the price is updated again.
...productCatalog.addProduct("Crab Juice"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.waitForQuantity("Crab Juice", 11),
...productCatalog.checkProductUoM("Crab Juice", "Units"),
...productCatalog.checkProductPrice("Crab Juice", "$ 2.45"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.checkProductPrice("Crab Juice", "$ 2.20"),
// Go back in the PO form view and check PO line price and qty is correct.
...productCatalog.goBackToOrder(),
...purchaseForm.checkLineValues(0, {
product: "Crab Juice",
discount: "10.20",
quantity: "12.00",
unit: "Units",
unitPrice: "2.45",
totalPrice: "$ 26.40",
}),
// Open the PO for the vendor selling product as liter.
{ trigger: "a[href='/odoo/purchase']", run: "click" },
{ trigger: "td[data-tooltip='PO/TEST/00001']", run: "click" },
...purchaseForm.openCatalog(),
...productCatalog.checkProductPrice("Crab Juice", "$ 1.55"),
...productCatalog.addProduct("Crab Juice"),
...productCatalog.waitForQuantity("Crab Juice", 1),
...productCatalog.checkProductUoM("Crab Juice", "L"),
...productCatalog.checkProductPrice("Crab Juice", "$ 1.55"),
// Go back in the PO form view and check PO line price and qty is correct.
...productCatalog.goBackToOrder(),
...purchaseForm.checkLineValues(0, {
product: "Crab Juice",
quantity: "1.00",
discount: "22.50",
unit: "L",
unitPrice: "2.00",
totalPrice: "$ 1.55",
}),
],
});

View File

@@ -0,0 +1,76 @@
import { inputFiles } from "@web/../tests/utils";
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("test_basic_purchase_flow_with_minimal_access_rights", {
steps: () => [
{
trigger: ".o_menuitem[href='/odoo/purchase']",
run: "click",
},
{
content: "Check that at least one RFQ is present in the view",
trigger: ".o_purchase_dashboard_list_view .o_data_row",
},
{
trigger: ".o_list_button_add",
run: "click",
},
{
trigger: ".o_input[id=partner_id_0]",
run: "edit partner_a",
},
{
trigger: ".dropdown-item:contains(partner_a)",
run: "click",
},
{
trigger: ".o_field_x2many_list_row_add > a",
run: "click",
},
{
trigger: ".o_data_row .o_input",
run: "edit Test Product",
},
{
trigger: ".dropdown-item:contains('Test Product')",
run: "click",
},
{
content: "Wait for the tax to be set by the onchange",
trigger: ".o_field_many2many_tags[name=tax_ids] .o_tag",
},
{
trigger: ".o_data_cell[name=price_unit]",
run: "click",
},
{
trigger: ".o_data_cell[name=price_unit] .o_input",
run: "edit 3",
},
{
trigger: "button[name=button_confirm]",
run: "click",
},
{
trigger: ".o_statusbar_status .o_arrow_button_current:contains(Purchase Order)",
},
{
content: "Upload the vendor bill",
trigger: ".o_widget_purchase_file_uploader",
run: async () => {
const testFile = new File(["Vendor, Bill"], "my_vendor_bill.png", {
type: "image/*",
});
await inputFiles(".o_widget_purchase_file_uploader input", [testFile]);
},
},
{
content: "Check that we are in the invoice form view",
trigger: ".o_statusbar_status:contains(Posted) .o_arrow_button_current:contains(Draft)",
},
{
content: "Check that the invoice is linked to the sale order",
trigger: "button[name=action_view_source_purchase_orders]",
},
],
});

View File

@@ -0,0 +1,160 @@
export const purchaseForm = {
checkLineValues(index, values) {
const fieldAndLabelDict = {
product: { fieldName: "product_id", label: "product" },
quantity: { fieldName: "product_qty", label: "quantity" },
unit: { fieldName: "product_uom_id", label: "unit of measure" },
unitPrice: { fieldName: "price_unit", label: "unit price" },
discount: { fieldName: "discount", label: "discount" },
totalPrice: { fieldName: "price_subtotal", label: "subtotal price" },
};
const trigger = `.o_form_renderer .o_list_view.o_field_x2many tbody tr.o_data_row:eq(${index})`;
const run = function ({ anchor }) {
const getFieldValue = (fieldName) => {
let selector = `td[name="${fieldName}"]`;
if (fieldName === "product_id") {
// Special case for the product field because it can be replace by another field
selector += ",td[name='product_template_id']";
}
const fieldEl = anchor.querySelector(selector);
return fieldEl ? fieldEl.innerText.replace(/\s/g, " ") : false;
};
for (const key in values) {
if (!Object.keys(fieldAndLabelDict).includes(key)) {
throw new Error(
`'checkPurchaseOrderLineValues' is called with unsupported key: ${key}`
);
}
const value = values[key];
const { fieldName, label } = fieldAndLabelDict[key];
const lineValue = getFieldValue(fieldName);
if (!lineValue) {
throw new Error(
`Purchase order line at index ${index} expected ${value} as ${label} but got nothing`
);
} else if (lineValue !== value) {
throw new Error(
`Purchase order line at index ${index} expected ${value} as ${label} but got ${lineValue} instead`
);
}
}
};
return [{ trigger, run }];
},
displayOptionalField(fieldName) {
return [
{
trigger:
".o_form_renderer .o_list_view.o_field_x2many .o_optional_columns_dropdown button",
run: "click",
},
{ trigger: `input[name="${fieldName}"]:not(:checked)`, run: "click" },
{ trigger: `th[data-name="${fieldName}"]` },
];
},
/**
* Clicks on the "Catalog" button below the purchase order lines.
*/
openCatalog() {
return [
{
content: "Go to product catalog",
trigger: ".o_field_x2many_list_row_add > button[name='action_add_from_catalog']",
run: "click",
},
];
},
/**
* Sets the vendor on a purchase order (must already on PO).
* @param {string} vendorName An existing partner.
*/
selectVendor(vendorName) {
return [
{
content: "Fill Vendor Field on PO",
trigger: ".o_field_res_partner_many2one[name='partner_id'] input",
run: `edit ${vendorName}`,
},
{
content: "Select vendor from many to one",
isActive: ["auto"],
trigger: `.ui-menu-item > a:contains(${vendorName})`,
run: "click",
},
];
},
/**
* Sets the WH on a purchase order (must already on PO).
* @param {string} warehouseName An existing warehouse.
*/
selectWarehouse(warehouseName) {
return [
{
content: "Fill Warehouse Field on PO",
trigger: ".o_field_many2one[name='picking_type_id'] input",
run: `edit ${warehouseName}`,
},
{
content: "Select BaseWarehouse as PO WH",
isActive: ["auto"],
trigger: `.ui-menu-item > a:contains(${warehouseName})`,
run: "click",
},
];
},
createNewPO() {
const content = "Create a New PO";
const trigger = ".o_list_button_add, .o_form_button_create";
return [{ content, trigger, run: "click" }];
},
};
export const productCatalog = {
addProduct(productName) {
const trigger = `.o_kanban_record:contains("${productName}") button:has(.fa-plus,.fa-shopping-cart)`;
return [{ trigger, run: "click" }];
},
/** Remove a product from the PO by clicking the "trash" button */
removeProduct(productName) {
const trigger = `.o_kanban_record:contains("${productName}") button:has(.fa-trash)`;
return [{ trigger, run: "click" }];
},
checkProductPrice(productName, price) {
const trigger = `.o_kanban_record:contains("${productName}") .o_product_catalog_price:contains("${price}")`;
const content = `Check that the kanban record card for product "${productName}" has a price of ${price}`;
return [{ content, trigger }];
},
checkProductUoM(productName, uom) {
const trigger = `.o_kanban_record:contains("${productName}") .o_product_catalog_quantity:contains("${uom}")`;
const content = `Check that the kanban record card for product "${productName}" uses ${uom} as the UoM`;
return [{ content, trigger }];
},
waitForQuantity(productName, quantity) {
const trigger = `.o_kanban_record:contains("${productName}") input[type=number]:value("${quantity}")`;
return [{ trigger }];
},
selectSearchPanelCategory(categoryName) {
const content = `Select the category ${categoryName}`;
const trigger = `.o_search_panel_label_title:contains("${categoryName}")`;
return [{ content, trigger, run: "click" }];
},
/**
* Clicks on the "Back to Order" button from the Catalog view
*/
goBackToOrder() {
const content = "Go back to the Order";
const trigger = "button.o-kanban-button-back";
return [{ content, trigger, run: "click" }];
},
};

Binary file not shown.