initial commit

This commit is contained in:
Abdul Aziz Amrullah 2026-05-04 09:53:17 +07:00
commit 7ecc056798
12 changed files with 327 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

34
README.md Normal file
View 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
View File

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

32
__manifest__.py Normal file
View 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
View File

@ -0,0 +1,4 @@
from . import (
loyalty_program,
res_partner,
)

22
models/loyalty_program.py Normal file
View 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
View 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

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

View 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;
}
}
});

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

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

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