first commit

This commit is contained in:
Suherdy Yacob 2026-05-22 22:10:58 +07:00
commit 5e80a2669e
21 changed files with 570 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@ -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/

37
README.md Normal file
View File

@ -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.

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

33
__manifest__.py Normal file
View File

@ -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"
}

Binary file not shown.

4
models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from . import pos_order
from . import pos_config
from . import res_config_settings
from . import product_template

Binary file not shown.

Binary file not shown.

14
models/pos_config.py Normal file
View File

@ -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.',
)

19
models/pos_order.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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.',
)

View File

@ -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();
}
}

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_ojol_discount_amount.OjolDiscountDialog">
<Dialog title="'Diskon Ojol'" size="'md'" contentClass="'ojol-discount-dialog p-3'">
<div class="d-flex flex-column gap-3">
<p class="mb-1 text-muted">
Enter the total discount amount for this Ojol order.
This will appear as a separate <strong>Diskon Ojol</strong> line.
</p>
<div class="input-group">
<span class="input-group-text fw-bold">Rp</span>
<input
id="ojol_discount_input"
type="number"
class="form-control form-control-lg text-end"
t-att-value="state.rawInput"
min="0"
step="1000"
placeholder="0"
t-on-input="onInput"
t-on-keydown="onKeydown"
/>
</div>
<t t-if="state.parsed > 0">
<div class="alert alert-info py-2 mb-0">
Discount: <strong t-esc="formatAmount(state.parsed)"/>
</div>
</t>
<t t-if="props.currentAmount > 0">
<div class="text-muted small">
Current discount: <span t-esc="formatAmount(props.currentAmount)"/>
(will be replaced)
</div>
</t>
</div>
<t t-set-slot="footer">
<button class="btn btn-secondary" t-on-click="onCancel">Cancel</button>
<t t-if="props.currentAmount > 0">
<button class="btn btn-outline-danger" t-on-click="onRemove">Remove Discount</button>
</t>
<button class="btn btn-primary" t-on-click="onConfirm" t-att-disabled="state.parsed &lt;= 0">
Apply Discount
</button>
</t>
</Dialog>
</t>
</templates>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="point_of_sale.Orderline" t-inherit-mode="extension">
<xpath expr="//li[@t-if='vals.discount']" position="replace">
<li t-if="vals.discount" class="price-per-unit">
<i class="fa fa-tag pe-1"/>
<t t-if="line.order_id and line.order_id.pricelist_id and line.order_id.pricelist_id.name == 'Ojol'">
<em>Discount <t t-esc="vals.discount"/> </em> off on <t t-esc="vals.noDiscountPrice"/>
</t>
<t t-else="">
<em><t t-esc="vals.discount" />% </em> discount off on <t t-esc="vals.noDiscountPrice"/>
</t>
</li>
</xpath>
</t>
</templates>

View File

@ -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;
},
});

View File

@ -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",
},
},
});

View File

@ -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,
});
},
});

View File

@ -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);
},
});

View File

@ -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);
},
});

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Direct configuration on Point of Sale (pos.config form) -->
<record id="view_pos_config_form_ojol" model="ir.ui.view">
<field name="name">pos.config.form.ojol</field>
<field name="model">pos.config</field>
<field name="inherit_id" ref="point_of_sale.pos_config_view_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='configuration']" position="inside">
<group string="Ojol Discount" name="ojol_discount_group">
<field name="ojol_discount_product_id"
options="{'no_create': True}"
placeholder="Select Ojol Discount Product…"/>
</group>
</xpath>
</field>
</record>
<!-- Configuration on Global Settings (res.config.settings form) -->
<record id="res_config_settings_view_form_ojol" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.ojol</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="95"/>
<field name="inherit_id" ref="point_of_sale.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//setting[@id='pos_global_discount']" position="after">
<setting string="Ojol Global Discount" help="Configure the dedicated service product used for order-level Ojol discounts.">
<field name="pos_ojol_discount_product_id"
options="{'no_create': True}"
placeholder="Select Ojol Discount Product…"/>
</setting>
</xpath>
</field>
</record>
</odoo>