commit 5e80a2669ec6be78fbf7bb8090df078748f85f3a Author: Suherdy Yacob Date: Fri May 22 22:10:58 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40ad844 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# OS / IDE files +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo + +# Local virtual environment +.venv/ +venv/ +env/ +.env + +# Unit test / coverage reports +.pytest_cache/ +.coverage +htmlcov/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..043ead9 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# POS Ojol Discount Amount + +Adds a custom global discount line workflow tailored for Ojol (online delivery apps like GoFood, GrabFood, ShopeeFood) in Odoo 19 Point of Sale. + +## Features + +- **Pricelist Integration**: Uses Ojol's specific menu pricing when active. +- **Global Order Discount**: Allows the cashier to apply a single, global order-level discount amount via a custom dialog (by pressing the discount/Rp button). +- **Automated Discount Line**: Generates a single negative-amount "Diskon Ojol" service line that scales across your order. +- **Configurable Per POS Config**: Set a specific discount product per POS configuration in the settings interface. +- **Robust Field & Model Protection**: Seamlessly loads all necessary relational schemas (`company_id`, etc.) and handles special service product templates, preventing initialization crashes in POS. + +--- + +## Configuration + +1. Go to **Point of Sale** > **Configuration** > **Settings**. +2. Locate the **Ojol Discount** section. +3. Select or create an **Ojol Discount Product**. + - *Note: This product should be a Service product with the same tax configuration as your menu items to ensure correct tax calculations.* +4. Save the settings. + +--- + +## Usage + +1. Open a new POS Session. +2. Select the Ojol pricelist. +3. Add items to the cart. +4. Click the custom discount button (`Rp`) on the numpad. +5. Enter the target discount amount in the custom popup dialog and click apply. +6. A negative-priced order line with the selected **Ojol Discount Product** will be appended to the cart. + +--- + +## License +Released under the [LGPL-3](https://www.gnu.org/licenses/lgpl-3.0.html) License. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..7920595 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,33 @@ +{ + "name": "POS Ojol Discount Amount", + "version": "19.0.2.0.0", + "category": "Point of Sale", + "summary": "Global Ojol discount line in POS — configurable discount product per POS config", + "author": "Suherdy Yacob", + "description": """ + Adds a configurable Ojol discount workflow to the POS App. + When the Ojol pricelist is active: + - Products are priced at their full Ojol pricelist price. + - The cashier presses the "Rp" numpad button to enter a total order discount. + - A single "Diskon Ojol" negative-price line is added to the order. + - The Ojol discount product is configurable per POS config in Settings. + """, + "depends": ["point_of_sale"], + "data": [ + "views/pos_config_views.xml", + ], + "assets": { + "point_of_sale._assets_pos": [ + "pos_ojol_discount_amount/static/src/overrides/models/product_pricelist.js", + "pos_ojol_discount_amount/static/src/overrides/models/pos_config.js", + "pos_ojol_discount_amount/static/src/overrides/models/pos_order.js", + "pos_ojol_discount_amount/static/src/overrides/models/orderline.js", + "pos_ojol_discount_amount/static/src/overrides/components/ojol_discount_dialog.xml", + "pos_ojol_discount_amount/static/src/overrides/components/ojol_discount_dialog.js", + "pos_ojol_discount_amount/static/src/overrides/screens/product_screen.js", + ] + }, + "installable": True, + "auto_install": False, + "license": "LGPL-3" +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..bc9c5f5 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..275d2c9 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +from . import pos_order +from . import pos_config +from . import res_config_settings +from . import product_template diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..41cb478 Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/pos_order.cpython-312.pyc b/models/__pycache__/pos_order.cpython-312.pyc new file mode 100644 index 0000000..48892d5 Binary files /dev/null and b/models/__pycache__/pos_order.cpython-312.pyc differ diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..a79bb87 --- /dev/null +++ b/models/pos_config.py @@ -0,0 +1,14 @@ +from odoo import fields, models + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + ojol_discount_product_id = fields.Many2one( + 'product.product', + string='Ojol Discount Product', + help='Product used to create the Ojol discount line in POS orders. ' + 'Should be a service product with the same tax configuration as menu items.', + ) + + diff --git a/models/pos_order.py b/models/pos_order.py new file mode 100644 index 0000000..7bec4bd --- /dev/null +++ b/models/pos_order.py @@ -0,0 +1,19 @@ +from odoo import api, fields, models + + +class PosOrderLine(models.Model): + _inherit = 'pos.order.line' + + is_ojol_discount = fields.Boolean( + string='Is Ojol Discount Line', + default=False, + help='If True, this line is the global Ojol discount line.', + ) + + @api.model + def _load_pos_data_fields(self, config): + fields_list = super()._load_pos_data_fields(config) + for f in ('is_ojol_discount',): + if f not in fields_list: + fields_list.append(f) + return fields_list diff --git a/models/product_template.py b/models/product_template.py new file mode 100644 index 0000000..005fe4a --- /dev/null +++ b/models/product_template.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from odoo import api, models +from odoo.fields import Domain + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + @api.model + def _load_pos_data_domain(self, data, config): + domain = super()._load_pos_data_domain(data, config) + if config.ojol_discount_product_id: + # Ensure the discount product's template is loaded regardless of other domain constraints + discount_tmpl_domain = [('id', '=', config.ojol_discount_product_id.product_tmpl_id.id)] + domain = Domain.OR([domain, discount_tmpl_domain]) + return domain diff --git a/models/res_config_settings.py b/models/res_config_settings.py new file mode 100644 index 0000000..7eccac8 --- /dev/null +++ b/models/res_config_settings.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + pos_ojol_discount_product_id = fields.Many2one( + 'product.product', + related='pos_config_id.ojol_discount_product_id', + string='Ojol Discount Product', + readonly=False, + help='Product used to create the Ojol discount line in POS orders.', + ) diff --git a/static/src/overrides/components/ojol_discount_dialog.js b/static/src/overrides/components/ojol_discount_dialog.js new file mode 100644 index 0000000..e024a49 --- /dev/null +++ b/static/src/overrides/components/ojol_discount_dialog.js @@ -0,0 +1,75 @@ +/** @odoo-module **/ + +import { Component, useState, onMounted } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { formatCurrency } from "@web/core/currency"; + +export class OjolDiscountDialog extends Component { + static template = "pos_ojol_discount_amount.OjolDiscountDialog"; + static components = { Dialog }; + static props = { + /** Called with the final amount (number) or null (cancel) */ + getPayload: Function, + close: Function, + /** Current discount amount already applied (0 if none) */ + currentAmount: { type: Number, optional: true }, + /** Currency id for formatting */ + currencyId: { type: Number, optional: true }, + }; + static defaultProps = { + currentAmount: 0, + currencyId: null, + }; + + setup() { + this.state = useState({ + rawInput: this.props.currentAmount > 0 ? String(this.props.currentAmount) : "", + parsed: this.props.currentAmount || 0, + }); + + onMounted(() => { + const input = document.getElementById("ojol_discount_input"); + if (input) { + input.focus(); + input.select(); + } + }); + } + + formatAmount(amount) { + if (this.props.currencyId) { + return formatCurrency(amount, this.props.currencyId); + } + return `Rp ${amount.toLocaleString("id-ID")}`; + } + + onInput(ev) { + const raw = ev.target.value; + this.state.rawInput = raw; + const parsed = parseFloat(raw); + this.state.parsed = isNaN(parsed) || parsed < 0 ? 0 : parsed; + } + + onKeydown(ev) { + if (ev.key === "Enter" && this.state.parsed > 0) { + this.onConfirm(); + } else if (ev.key === "Escape") { + this.onCancel(); + } + } + + onConfirm() { + this.props.getPayload(this.state.parsed); + this.props.close(); + } + + onRemove() { + this.props.getPayload(0); + this.props.close(); + } + + onCancel() { + this.props.getPayload(null); + this.props.close(); + } +} diff --git a/static/src/overrides/components/ojol_discount_dialog.xml b/static/src/overrides/components/ojol_discount_dialog.xml new file mode 100644 index 0000000..5415d0d --- /dev/null +++ b/static/src/overrides/components/ojol_discount_dialog.xml @@ -0,0 +1,52 @@ + + + + + +
+

+ Enter the total discount amount for this Ojol order. + This will appear as a separate Diskon Ojol line. +

+ +
+ Rp + +
+ + +
+ Discount: +
+
+ +
+ Current discount: + (will be replaced) +
+
+
+ + + + + + + + +
+
+ +
diff --git a/static/src/overrides/components/orderline.xml b/static/src/overrides/components/orderline.xml new file mode 100644 index 0000000..2017ad7 --- /dev/null +++ b/static/src/overrides/components/orderline.xml @@ -0,0 +1,16 @@ + + + + +
  • + + + Discount off on + + + % discount off on + +
  • +
    +
    +
    diff --git a/static/src/overrides/models/orderline.js b/static/src/overrides/models/orderline.js new file mode 100644 index 0000000..4c3ab2f --- /dev/null +++ b/static/src/overrides/models/orderline.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import { PosOrderline } from "@point_of_sale/app/models/pos_order_line"; +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { patch } from "@web/core/utils/patch"; + +/** + * Register extra fields on PosOrderline so the model store + * knows about our custom columns. + */ +patch(PosOrderline, { + extraFields: { + ...(PosOrderline.extraFields || {}), + is_ojol_discount: { + model: "pos.order.line", + name: "is_ojol_discount", + type: "boolean", + default: false, + }, + }, +}); + +patch(PosOrder.prototype, { + /** + * Override getTotalDiscount to include the Ojol global discount line. + */ + getTotalDiscount() { + const base = super.getTotalDiscount(); + const ojolLine = this.lines.find((l) => l.is_ojol_discount); + if (ojolLine) { + return base + Math.abs(ojolLine.price_unit * ojolLine.qty); + } + return base; + }, +}); diff --git a/static/src/overrides/models/pos_config.js b/static/src/overrides/models/pos_config.js new file mode 100644 index 0000000..f180245 --- /dev/null +++ b/static/src/overrides/models/pos_config.js @@ -0,0 +1,16 @@ +/** @odoo-module **/ + +import { PosConfig } from "@point_of_sale/app/models/pos_config"; +import { patch } from "@web/core/utils/patch"; + +patch(PosConfig, { + extraFields: { + ...(PosConfig.extraFields || {}), + ojol_discount_product_id: { + model: "pos.config", + name: "ojol_discount_product_id", + type: "many2one", + relation: "product.product", + }, + }, +}); diff --git a/static/src/overrides/models/pos_order.js b/static/src/overrides/models/pos_order.js new file mode 100644 index 0000000..ee872e4 --- /dev/null +++ b/static/src/overrides/models/pos_order.js @@ -0,0 +1,68 @@ +/** @odoo-module **/ + +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { patch } from "@web/core/utils/patch"; + +patch(PosOrder.prototype, { + /** + * Returns the existing Ojol discount orderline, or null. + */ + getOjolDiscountLine() { + return this.lines.find((l) => l.is_ojol_discount) || null; + }, + + /** + * Returns the current Ojol discount amount (0 if none). + */ + getOjolDiscount() { + const line = this.getOjolDiscountLine(); + return line ? Math.abs(line.price_unit * line.qty) : 0; + }, + + /** + * Create or update the Ojol global discount line. + * @param {number} amount Positive discount amount (Rp). Pass 0 to remove. + */ + setOjolDiscount(amount) { + // Remove any existing Ojol discount line + const existing = this.getOjolDiscountLine(); + if (existing) { + existing.delete(); + } + + if (!amount || amount <= 0) { + return; + } + + const discProduct = this.config.ojol_discount_product_id; + if (!discProduct) { + console.warn( + "[OjolDiscount] No Ojol discount product configured in POS settings." + ); + return; + } + + // The discount product may be a product.product or product.template + // config field is Many2one to product.product + const productVariant = discProduct; + const productTmpl = productVariant.product_tmpl_id; + + if (!productTmpl) { + console.warn("[OjolDiscount] Cannot resolve product template for discount product."); + return; + } + + // Create negative-price discount line + this.models["pos.order.line"].create({ + order_id: this, + product_id: productVariant, + product_tmpl_id: productTmpl, + price_unit: -Math.abs(amount), + qty: 1, + discount: 0, + price_type: "manual", + full_product_name: "Diskon Ojol", + is_ojol_discount: true, + }); + }, +}); diff --git a/static/src/overrides/models/product_pricelist.js b/static/src/overrides/models/product_pricelist.js new file mode 100644 index 0000000..7af31ba --- /dev/null +++ b/static/src/overrides/models/product_pricelist.js @@ -0,0 +1,49 @@ +/** @odoo-module **/ + +import { ProductPricelist } from "@point_of_sale/app/models/product_pricelist"; +import { ProductTemplate } from "@point_of_sale/app/models/product_template"; +import { patch } from "@web/core/utils/patch"; + +/** + * Patch ProductPricelist to ensure rule indexes are always up-to-date. + * The lazy-recompute guard (_indexedItemCount) ensures that if items are loaded + * after the pricelist is set up, the indexes will be recomputed on first use. + */ +patch(ProductPricelist.prototype, { + setup() { + super.setup(...arguments); + this._indexedItemCount = this.item_ids?.length || 0; + }, + + getCategoryRulesIds(categoryIds) { + if (this._indexedItemCount !== (this.item_ids?.length || 0)) { + this.computeRuleIndexes(); + this._indexedItemCount = this.item_ids?.length || 0; + } + return super.getCategoryRulesIds(...arguments); + }, + + getGlobalRulesIds() { + if (this._indexedItemCount !== (this.item_ids?.length || 0)) { + this.computeRuleIndexes(); + this._indexedItemCount = this.item_ids?.length || 0; + } + return super.getGlobalRulesIds(...arguments); + }, + + getRulesByProductId(productId) { + if (this._indexedItemCount !== (this.item_ids?.length || 0)) { + this.computeRuleIndexes(); + this._indexedItemCount = this.item_ids?.length || 0; + } + return super.getRulesByProductId(...arguments); + }, + + getRulesByTmplId(tmplId) { + if (this._indexedItemCount !== (this.item_ids?.length || 0)) { + this.computeRuleIndexes(); + this._indexedItemCount = this.item_ids?.length || 0; + } + return super.getRulesByTmplId(...arguments); + }, +}); diff --git a/static/src/overrides/screens/product_screen.js b/static/src/overrides/screens/product_screen.js new file mode 100644 index 0000000..605eeb3 --- /dev/null +++ b/static/src/overrides/screens/product_screen.js @@ -0,0 +1,66 @@ +/** @odoo-module **/ + +import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen"; +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; +import { OjolDiscountDialog } from "../components/ojol_discount_dialog"; +import { makeAwaitable, ask } from "@point_of_sale/app/utils/make_awaitable_dialog"; +import { _t } from "@web/core/l10n/translation"; + +patch(ProductScreen.prototype, { + setup() { + super.setup(...arguments); + this.dialog = useService("dialog"); + }, + + getNumpadButtons() { + const buttons = super.getNumpadButtons(); + const discountButton = buttons.find((b) => b.value === "discount"); + if (discountButton && this.pos.selectedOrder?.pricelist_id?.name === "Ojol") { + discountButton.text = "Rp"; + discountButton.disabled = false; + } + return buttons; + }, + + /** + * Override numpad click: intercept "discount" mode in Ojol pricelist + * to show the global discount dialog instead of per-line discount. + */ + async onNumpadClick(buttonValue) { + const order = this.pos.selectedOrder; + if ( + buttonValue === "discount" && + order?.pricelist_id?.name === "Ojol" + ) { + await this._showOjolDiscountDialog(order); + return; + } + return super.onNumpadClick(buttonValue); + }, + + async _showOjolDiscountDialog(order) { + if (!this.pos.config.ojol_discount_product_id) { + await ask(this.dialog, { + title: _t("Configuration Missing"), + body: _t("Ojol discount product is not configured. Please configure it in POS Settings first."), + }); + return; + } + + const currentAmount = order.getOjolDiscount(); + const currencyId = this.pos.currency?.id; + + const amount = await makeAwaitable(this.dialog, OjolDiscountDialog, { + currentAmount, + currencyId, + }); + + // null = user cancelled, undefined = closed without action + if (amount === null || amount === undefined) { + return; + } + + order.setOjolDiscount(amount); + }, +}); diff --git a/views/pos_config_views.xml b/views/pos_config_views.xml new file mode 100644 index 0000000..d930b88 --- /dev/null +++ b/views/pos_config_views.xml @@ -0,0 +1,35 @@ + + + + + pos.config.form.ojol + pos.config + + + + + + + + + + + + + res.config.settings.view.form.inherit.ojol + res.config.settings + + + + + + + + + + +