commit 7ecc0567986dd825b4fc14f6c91fe4e7b643e434 Author: Abdul Aziz Amrullah Date: Mon May 4 09:53:17 2026 +0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d876a9c --- /dev/null +++ b/README.md @@ -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. 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..0340bfa --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..7bac6b8 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +from . import ( + loyalty_program, + res_partner, +) diff --git a/models/loyalty_program.py b/models/loyalty_program.py new file mode 100644 index 0000000..11cafd1 --- /dev/null +++ b/models/loyalty_program.py @@ -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 diff --git a/models/res_partner.py b/models/res_partner.py new file mode 100644 index 0000000..7456b27 --- /dev/null +++ b/models/res_partner.py @@ -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 diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js new file mode 100644 index 0000000..b27ccef --- /dev/null +++ b/static/src/app/models/pos_order.js @@ -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; + }, +}); diff --git a/static/src/app/screens/partner_list_patch.js b/static/src/app/screens/partner_list_patch.js new file mode 100644 index 0000000..44c6ffc --- /dev/null +++ b/static/src/app/screens/partner_list_patch.js @@ -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; + } + } +}); diff --git a/static/src/app/screens/partner_list_patch.xml b/static/src/app/screens/partner_list_patch.xml new file mode 100644 index 0000000..d9d08e6 --- /dev/null +++ b/static/src/app/screens/partner_list_patch.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/views/loyalty_program_views.xml b/views/loyalty_program_views.xml new file mode 100644 index 0000000..3398133 --- /dev/null +++ b/views/loyalty_program_views.xml @@ -0,0 +1,24 @@ + + + + + loyalty.program.view.form.inherit.multi.level + loyalty.program + + + + + + + + + + + + + + + + + + diff --git a/views/res_partner_views.xml b/views/res_partner_views.xml new file mode 100644 index 0000000..76eb3fa --- /dev/null +++ b/views/res_partner_views.xml @@ -0,0 +1,15 @@ + + + + + res.partner.view.form.inherit.loyalty.multi.level + res.partner + + + + + + + + +