initial commit
This commit is contained in:
commit
7ecc056798
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__
|
||||
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
||||
# POS Loyalty Multi-Level
|
||||
|
||||
## Overview
|
||||
This custom Odoo 19 module extends the functionalities of the Point of Sale (POS) loyalty module (`pos_loyalty`). By default, Odoo activates all applicable loyalty programs that meet the criteria when a customer is selected. This module modifies that behavior to support tiered/multi-level membership programs defined via custom fields.
|
||||
|
||||
## Features
|
||||
When evaluating loyalty programs for a transaction, this module checks the `multi_level_membership` flag on running loyalty programs:
|
||||
|
||||
- **Non-Multi-Level Programs:** Any loyalty programs with `multi_level_membership` set to `False` (or undefined) will behave normally. All applicable programs will be activated simultaneously.
|
||||
- **Multi-Level Programs:** If there are several loyalty programs with `multi_level_membership` set to `True`, the module will restrict activation to **only 1** of those programs:
|
||||
1. It will first attempt to match the program defined in the customer's `membership_level_id` field.
|
||||
2. If the customer does not have a membership level defined, or if the specified membership program criteria is not met, the module falls back to activating the multi-level program with the smallest `minimum_spend` value.
|
||||
- **Real-time Membership Updates:** Whenever a customer is selected from the POS Partner List, their latest details (specifically their membership level) are fetched instantly from the server. This prevents utilizing an outdated membership tier if changes were made in the backend during an active POS session.
|
||||
- **On-Demand Customer Sync:** Provides an **"Update Data"** button globally accessible in the POS Customer List. Pressing it dynamically fetches any newly registered accounts created during the active POS session straight into the local offline cache without needing to reload or close the POS.
|
||||
|
||||
## Included Fields
|
||||
This module installs the following native fields:
|
||||
|
||||
1. **`loyalty.program` Model:**
|
||||
- `multi_level_membership`: Boolean
|
||||
- `minimum_spend`: Float
|
||||
2. **`res.partner` Model:**
|
||||
- `membership_level_id`: Many2one (Relational reference to `loyalty.program`)
|
||||
|
||||
## Technical Details
|
||||
This module modifies the loading process of the POS session by injecting the new custom fields into the Point of Sale frontend environment directly from the models:
|
||||
- Extends `_load_pos_data_fields` in `models/loyalty_program.py` and `models/res_partner.py`.
|
||||
- Patches `PartnerList.prototype.clickPartner` in `static/src/app/screens/partner_list_patch.js` to trigger a backend `data.read` lookup over the network to instantly refresh the local reactive partner state when selected.
|
||||
- Extends the `point_of_sale.PartnerList` XML template in `static/src/app/screens/partner_list_patch.xml` to inject the synchronization button.
|
||||
- Handles the bulk sync in Javascript via `updateCustomerData()` utilizing Odoo native `searchRead` to load missing active models locally.
|
||||
- Patches `PosOrder.prototype._programIsApplicable` in `static/src/app/models/pos_order.js` to augment the frontend eligibility checks cleanly and safely using Owl `patch`.
|
||||
|
||||
## License
|
||||
See the `__manifest__.py` file for license details.
|
||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
32
__manifest__.py
Normal file
32
__manifest__.py
Normal file
@ -0,0 +1,32 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': 'POS Loyalty Multi-Level',
|
||||
'version': '1.0',
|
||||
'category': 'Sales/Point of Sale',
|
||||
'summary': 'Advanced tiered multi-level membership programs and real-time customer data syncing for POS Loyalty.',
|
||||
'author': 'Abdul Aziz Amrullah',
|
||||
'description': """
|
||||
POS Loyalty Multi-Level
|
||||
=========================
|
||||
Extends the native Point of Sale (POS) loyalty module to support advanced tiered multi-level membership programs.
|
||||
|
||||
Key Features:
|
||||
- **Tiered Memberships**: Restricts POS loyalty program activation to a single, specific tier explicitly assigned to the customer, bypassing Odoo's default behavior of activating all eligible programs at once.
|
||||
- **Fallback Logic**: If a customer lacks an explicit membership tier, automatically falls back to activating the multi-level program with the lowest minimum spend threshold.
|
||||
- **Real-Time Data Sync**: Introduces an "Update Data" button globally on the POS Customer List to dynamically fetch newly registered customers or membership updates without refreshing the entire POS session.
|
||||
- **Native Integration**: Seamlessly adds native configuration fields to the Customer form and Loyalty Program form, completely decoupled from Odoo Studio.
|
||||
""",
|
||||
'depends': ['base', 'point_of_sale', 'pos_loyalty'],
|
||||
'data': [
|
||||
'views/loyalty_program_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'point_of_sale._assets_pos': [
|
||||
'pos_loyalty_multi_level/static/src/app/**/*',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
4
models/__init__.py
Normal file
4
models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from . import (
|
||||
loyalty_program,
|
||||
res_partner,
|
||||
)
|
||||
22
models/loyalty_program.py
Normal file
22
models/loyalty_program.py
Normal file
@ -0,0 +1,22 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
class LoyaltyProgram(models.Model):
|
||||
_inherit = 'loyalty.program'
|
||||
|
||||
multi_level_membership = fields.Boolean(
|
||||
string='Multi-Level Membership',
|
||||
help='If checked, this program is treated as a tiered membership program. Only one tiered program can be active at a time based on the customer.'
|
||||
)
|
||||
minimum_spend = fields.Float(
|
||||
string='Minimum Spend',
|
||||
help='Used as a fallback mechanism for multi-level membership if the customer does not have an explicit level assigned. The one with the lowest spend will be chosen.'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_fields(self, config_id):
|
||||
fields_list = super()._load_pos_data_fields(config_id)
|
||||
# Add custom fields so they are loaded in the POS frontend
|
||||
fields_list.extend(['multi_level_membership', 'minimum_spend'])
|
||||
return fields_list
|
||||
20
models/res_partner.py
Normal file
20
models/res_partner.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
membership_level_id = fields.Many2one(
|
||||
'loyalty.program',
|
||||
string='Membership Level',
|
||||
help='Specific multi-level membership assigned to this customer.'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _load_pos_data_fields(self, config_id):
|
||||
fields_list = super()._load_pos_data_fields(config_id)
|
||||
# Add custom field for customer membership level
|
||||
fields_list.append('membership_level_id')
|
||||
return fields_list
|
||||
91
static/src/app/models/pos_order.js
Normal file
91
static/src/app/models/pos_order.js
Normal file
@ -0,0 +1,91 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { PosOrder } from "@point_of_sale/app/models/pos_order";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/**
|
||||
* Safely extract a numeric ID from a Many2One field value.
|
||||
* Handles three forms Odoo may return:
|
||||
* - raw integer: 5
|
||||
* - [id, name] tuple: [5, "Gold"]
|
||||
* - record object: { id: 5, name: "Gold", ... }
|
||||
*/
|
||||
function resolveManyToOneId(value) {
|
||||
if (!value && value !== 0) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return parseInt(value[0], 10);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return parseInt(value.id, 10);
|
||||
}
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
// Track recursion depth to prevent infinite loops when filtering multiLevelPrograms
|
||||
let _checkingMultiLevel = false;
|
||||
|
||||
patch(PosOrder.prototype, {
|
||||
_programIsApplicable(program) {
|
||||
// Evaluate base program applicability first.
|
||||
const isApplicable = super._programIsApplicable(...arguments);
|
||||
if (!isApplicable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Custom Logic for Multi-Level Membership.
|
||||
// Guard against recursive calls triggered when filtering all programs below.
|
||||
if (program.multi_level_membership && !_checkingMultiLevel) {
|
||||
// Retrieve all loyalty programs
|
||||
const allPrograms = this.models['loyalty.program'].getAll();
|
||||
|
||||
// Set guard BEFORE filtering to prevent re-entry into this block
|
||||
_checkingMultiLevel = true;
|
||||
let multiLevelPrograms;
|
||||
try {
|
||||
// Filter programs that have the multi-level flag and pass the base applicability check.
|
||||
// With the guard set, this._programIsApplicable(p) will skip the custom block,
|
||||
// effectively calling only the base logic for each candidate program.
|
||||
multiLevelPrograms = allPrograms.filter(
|
||||
(p) => p.multi_level_membership && this._programIsApplicable(p)
|
||||
);
|
||||
} finally {
|
||||
_checkingMultiLevel = false;
|
||||
}
|
||||
|
||||
// If there are no applicable multi-level programs, block all of them.
|
||||
if (multiLevelPrograms.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let bestProgram = null;
|
||||
const partner = this.getPartner();
|
||||
|
||||
// If the partner is set and has a membership level, try to match it.
|
||||
if (partner && partner.membership_level_id) {
|
||||
const membershipId = resolveManyToOneId(partner.membership_level_id);
|
||||
if (membershipId) {
|
||||
// Find the matching program among applicable multi-level programs
|
||||
bestProgram = multiLevelPrograms.find((p) => p.id === membershipId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: pick the program with the smallest minimum_spend
|
||||
if (!bestProgram) {
|
||||
bestProgram = multiLevelPrograms.reduce((prev, curr) => {
|
||||
const prevSpend = prev.minimum_spend || 0;
|
||||
const currSpend = curr.minimum_spend || 0;
|
||||
return currSpend < prevSpend ? curr : prev;
|
||||
}, multiLevelPrograms[0]);
|
||||
}
|
||||
|
||||
// Only permit the chosen program to be active
|
||||
if (program.id !== bestProgram.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
71
static/src/app/screens/partner_list_patch.js
Normal file
71
static/src/app/screens/partner_list_patch.js
Normal file
@ -0,0 +1,71 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { PartnerList } from "@point_of_sale/app/screens/partner_list/partner_list";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(PartnerList.prototype, {
|
||||
async clickPartner(partner) {
|
||||
if (partner && partner.id && navigator.onLine) {
|
||||
try {
|
||||
console.log(`[pos_loyalty_multi_level] Event: clickPartner. Fetching latest data for partner ID: ${partner.id} to ensure membership level is up-to-date.`);
|
||||
await this.pos.data.read("res.partner", [partner.id]);
|
||||
console.log(`[pos_loyalty_multi_level] Successfully updated partner ${partner.id} local record. New membership level is:`, partner.membership_level_id);
|
||||
} catch (error) {
|
||||
console.warn(`[pos_loyalty_multi_level] Offline or failed to fetch updated partner ${partner.id} data:`, error);
|
||||
}
|
||||
}
|
||||
return super.clickPartner(...arguments);
|
||||
},
|
||||
|
||||
async updateCustomerData() {
|
||||
if (!navigator.onLine) {
|
||||
if (this.notification) {
|
||||
this.notification.add("You must be online to update customer data.", 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.state.loading = true;
|
||||
console.log("[pos_loyalty_multi_level] Fetching the latest 100 customers from the backend...");
|
||||
|
||||
// In Odoo 19, data.searchRead fetches data AND creates/updates the local reactive models invisibly
|
||||
const newPartners = await this.pos.data.searchRead(
|
||||
"res.partner",
|
||||
[["active", "=", true]],
|
||||
this.pos.data.fields["res.partner"],
|
||||
{ limit: 100, order: 'id desc' }
|
||||
);
|
||||
|
||||
let addedCount = 0;
|
||||
for (const partner of newPartners) {
|
||||
// Fetch the instantiated local model proxy instead of the raw data dict
|
||||
const localPartner = this.pos.models["res.partner"].get(partner.id);
|
||||
if (localPartner && !this.loadedPartnerIds.has(localPartner.id)) {
|
||||
this.loadedPartnerIds.add(localPartner.id);
|
||||
this.state.loadedPartners.push(localPartner);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh initial view
|
||||
this.state.initialPartners = this.pos.models["res.partner"].filter((p) => {
|
||||
const par = p.property_account_receivable_id;
|
||||
return !par || par.non_trade !== true;
|
||||
});
|
||||
|
||||
if (this.notification) {
|
||||
this.notification.add(`Successfully synced list. Found ${addedCount} new customers.`, 3000);
|
||||
}
|
||||
console.log(`[pos_loyalty_multi_level] Fetch complete. Added ${addedCount} customers to the session.`);
|
||||
|
||||
} catch (e) {
|
||||
console.warn("[pos_loyalty_multi_level] Failed to update customer data:", e);
|
||||
if (this.notification) {
|
||||
this.notification.add("Failed to fetch customer data from server.", 3000);
|
||||
}
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
12
static/src/app/screens/partner_list_patch.xml
Normal file
12
static/src/app/screens/partner_list_patch.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_loyalty_multi_level.PartnerList" t-inherit="point_of_sale.PartnerList" t-inherit-mode="extension">
|
||||
<xpath expr="//t[@t-set-slot='header']/button" position="after">
|
||||
<button class="btn btn-secondary btn-lg lh-lg me-2 ms-2" role="img" aria-label="Update Customer Data"
|
||||
t-on-click="() => this.updateCustomerData()"
|
||||
title="Fetch newly registered customers from the backend">
|
||||
<i class="fa fa-refresh"></i> Update Data
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
24
views/loyalty_program_views.xml
Normal file
24
views/loyalty_program_views.xml
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="loyalty_program_view_form_inherit_multi_level" model="ir.ui.view">
|
||||
<field name="name">loyalty.program.view.form.inherit.multi.level</field>
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<notebook position="inside">
|
||||
<page string="Multi-Level Membership" name="multi_level_membership_page">
|
||||
<group>
|
||||
<group>
|
||||
<field name="multi_level_membership" widget="boolean_toggle"/>
|
||||
<field name="minimum_spend" invisible="multi_level_membership == False"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
15
views/res_partner_views.xml
Normal file
15
views/res_partner_views.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="view_partner_form_inherit_loyalty_multi_level" model="ir.ui.view">
|
||||
<field name="name">res.partner.view.form.inherit.loyalty.multi.level</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='sales_purchases']//group[@name='sale']" position="inside">
|
||||
<field name="membership_level_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
Loading…
Reference in New Issue
Block a user