initial commit
This commit is contained in:
commit
f9b7c799b1
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
__pycache__
|
||||||
112
README.md
Normal file
112
README.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# POS Loyalty Auto Level Update
|
||||||
|
|
||||||
|
Automatically updates a customer's loyalty membership level after every POS transaction (sale or refund).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This module extends Odoo 19's Point of Sale to support **automatic multi-level loyalty membership management**. When a customer completes a purchase or receives a refund at the POS, the module recalculates their total spending and assigns the appropriate loyalty tier — no manual intervention required.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Customer pays / gets refund at POS
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Sum all paid pos.order amounts for this customer
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Fetch loyalty.program records where
|
||||||
|
multi_level_membership = True
|
||||||
|
(sorted by minimum_spend ASC)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Find the highest tier where
|
||||||
|
minimum_spend ≤ total purchases
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Compare with current res.partner.membership_level_id
|
||||||
|
│
|
||||||
|
├── Different → Update to new level
|
||||||
|
└── Same → Do nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step-by-Step
|
||||||
|
|
||||||
|
1. **Trigger** — The module hooks into `action_pos_order_paid()`, which is called every time a POS order is finalized (both regular sales and refunds).
|
||||||
|
|
||||||
|
2. **Calculate Total Purchases** — Sums the `amount_total` field from all `pos.order` records with state `paid` or `done` for the customer. Refund orders naturally have a negative `amount_total`, so they reduce the total automatically.
|
||||||
|
|
||||||
|
3. **Fetch Loyalty Levels** — Retrieves all `loyalty.program` records where the `multi_level_membership` field (Boolean) is `True`, sorted by `minimum_spend` in ascending order.
|
||||||
|
|
||||||
|
4. **Determine Level** — Walks through the sorted programs and selects the **highest tier** where the customer's total purchases meet or exceed `minimum_spend`.
|
||||||
|
|
||||||
|
5. **Transfer Points** (If upgraded/downgraded) — Finds the customer's loyalty card for the old membership program. If it has points, transfers those points to the new program's loyalty card (creating one if it doesn't already exist), then resets the old card's points to 0.
|
||||||
|
|
||||||
|
6. **Compare & Update** — Checks the customer's current `membership_level_id` (Many2one to `loyalty.program`) on `res.partner`:
|
||||||
|
- If the level has **changed** → updates the field with the new program.
|
||||||
|
- If the level is the **same** → does nothing.
|
||||||
|
- If the customer doesn't meet **any** tier → clears the membership level.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `point_of_sale` | Provides `pos.order` model and POS transaction flow |
|
||||||
|
| `pos_loyalty` | Provides `loyalty.program` model |
|
||||||
|
|
||||||
|
## Required Studio Fields
|
||||||
|
|
||||||
|
This module relies on custom fields created via Odoo Studio:
|
||||||
|
|
||||||
|
| Model | Field | Type | Description |
|
||||||
|
|-------|-------|------|-------------|
|
||||||
|
| `loyalty.program` | `multi_level_membership` | Boolean | Marks this program as part of the multi-level membership system |
|
||||||
|
| `loyalty.program` | `minimum_spend` | Float | Minimum total spend required to qualify for this level |
|
||||||
|
| `res.partner` | `membership_level_id` | Many2one → `loyalty.program` | The customer's current loyalty membership level |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Suppose you have three loyalty tiers configured:
|
||||||
|
|
||||||
|
| Loyalty Program | minimum_spend |
|
||||||
|
|----------------|----------------------|
|
||||||
|
| Bronze | 0 |
|
||||||
|
| Silver | 500,000 |
|
||||||
|
| Gold | 1,500,000 |
|
||||||
|
|
||||||
|
**Scenario — Upgrade:**
|
||||||
|
- Customer has accumulated **600,000** in total POS purchases.
|
||||||
|
- Current level: **Bronze**
|
||||||
|
- After a new purchase, total reaches **600,000** which is ≥ 500,000 (Silver) but < 1,500,000 (Gold).
|
||||||
|
- Result: Customer is upgraded to **Silver**.
|
||||||
|
|
||||||
|
**Scenario — Downgrade after refund:**
|
||||||
|
- Customer's total was **520,000** (Silver level).
|
||||||
|
- A refund of **50,000** brings the total down to **470,000**.
|
||||||
|
- 470,000 < 500,000 (Silver threshold), so customer drops back to **Bronze**.
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pos_loyalty_auto_level/
|
||||||
|
├── __init__.py
|
||||||
|
├── __manifest__.py
|
||||||
|
├── README.md
|
||||||
|
└── models/
|
||||||
|
├── __init__.py
|
||||||
|
└── pos_order.py # Core logic: overrides action_pos_order_paid()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Place the `pos_loyalty_auto_level` folder in your Odoo `custom/` addons directory.
|
||||||
|
2. Update the apps list: **Settings → Apps → Update Apps List**.
|
||||||
|
3. Search for **"POS Loyalty Auto Level Update"** and click **Install**.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The module logs membership level changes at `INFO` level and unchanged checks at `DEBUG` level. Check your Odoo server logs for entries from `odoo.addons.pos_loyalty_auto_level.models.pos_order`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPL-3
|
||||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
30
__manifest__.py
Normal file
30
__manifest__.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
{
|
||||||
|
'name': 'POS Loyalty Auto Level Update',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Sales/Point of Sale',
|
||||||
|
'summary': 'Automatically update customer membership level after POS transactions',
|
||||||
|
'author': 'Abdul Aziz Amrullah',
|
||||||
|
'description': """
|
||||||
|
POS Loyalty Auto Level Update
|
||||||
|
================================
|
||||||
|
This module automates the management of customer loyalty membership tiers directly within the Point of Sale system.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
1. **Automated Lifetime Spend Tracking**: After every POS sale or refund, the module calculates the customer's cumulative purchase amount from all `paid` or `done` `pos.order` records.
|
||||||
|
2. **Tier Evaluation**: Compares the customer's total spend against the `minimum_spend` thresholds defined on `loyalty.program` records (specifically those marked as `multi_level_membership`).
|
||||||
|
3. **Seamless Upgrades/Downgrades**: Automatically determines the highest qualifying membership level and updates the customer's `membership_level_id` on their `res.partner` profile.
|
||||||
|
4. **Intelligent Point Consolidation**: If a customer changes tiers, the module seamlessly sweeps loyalty points from any of their older tier cards and consolidates them into the new membership card. It hooks directly into the POS frontend's point confirmation (`confirm_coupon_programs`) to ensure that even points earned in the current transaction are properly swept over.
|
||||||
|
5. **Detailed Logging**: Provides developer-level console logs in the browser to trace the loyalty processing and synchronization steps during checkout.
|
||||||
|
""",
|
||||||
|
'depends': ['point_of_sale', 'pos_loyalty'],
|
||||||
|
'data': [],
|
||||||
|
'assets': {
|
||||||
|
'point_of_sale._assets_pos': [
|
||||||
|
'pos_loyalty_auto_level/static/src/app/**/*',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import pos_order
|
||||||
153
models/pos_order.py
Normal file
153
models/pos_order.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PosOrder(models.Model):
|
||||||
|
_inherit = 'pos.order'
|
||||||
|
|
||||||
|
def action_pos_order_paid(self):
|
||||||
|
"""Override to update the customer's membership level after payment."""
|
||||||
|
res = super().action_pos_order_paid()
|
||||||
|
for order in self:
|
||||||
|
if order.partner_id:
|
||||||
|
order._update_customer_membership_level()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def confirm_coupon_programs(self, coupon_data):
|
||||||
|
"""Also update membership after the frontend confirms coupon programs to catch current order's points."""
|
||||||
|
res = super().confirm_coupon_programs(coupon_data)
|
||||||
|
for order in self:
|
||||||
|
if order.partner_id:
|
||||||
|
order._update_customer_membership_level()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _update_customer_membership_level(self):
|
||||||
|
"""Determine and update the customer's loyalty membership level.
|
||||||
|
|
||||||
|
1. Sum all paid pos.order amount_total for this partner.
|
||||||
|
2. Fetch all loyalty.program where multi_level_membership = True,
|
||||||
|
ordered by minimum_spend ascending.
|
||||||
|
3. Find the highest level the customer qualifies for
|
||||||
|
(highest minimum_spend <= total purchases).
|
||||||
|
4. Update res.partner.membership_level_id if it differs.
|
||||||
|
5. Consolidate points from other tier cards.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
partner = self.partner_id
|
||||||
|
if not partner:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Calculate total purchases for this customer (only paid/done orders)
|
||||||
|
total_purchases = self._get_customer_total_purchases(partner)
|
||||||
|
|
||||||
|
# 2. Get all multi-level loyalty programs sorted by minimum spend ASC
|
||||||
|
loyalty_programs = self.env['loyalty.program'].sudo().search(
|
||||||
|
[('multi_level_membership', '=', True)],
|
||||||
|
order='minimum_spend asc',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not loyalty_programs:
|
||||||
|
_logger.info(
|
||||||
|
'No multi-level loyalty programs found. '
|
||||||
|
'Skipping membership level update for partner %s (ID: %s).',
|
||||||
|
partner.name, partner.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Determine the appropriate level
|
||||||
|
matched_program = None
|
||||||
|
for program in loyalty_programs:
|
||||||
|
min_spend = program.minimum_spend or 0.0
|
||||||
|
if total_purchases >= min_spend:
|
||||||
|
matched_program = program
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched_program:
|
||||||
|
_logger.info(
|
||||||
|
'Customer %s (ID: %s) total purchases %.2f do not meet '
|
||||||
|
'the minimum spend of any multi-level loyalty program. '
|
||||||
|
'Clearing membership level.',
|
||||||
|
partner.name, partner.id, total_purchases,
|
||||||
|
)
|
||||||
|
if partner.membership_level_id:
|
||||||
|
partner.sudo().write({'membership_level_id': False})
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Compare with current level and update if different
|
||||||
|
current_level_id = partner.membership_level_id.id if partner.membership_level_id else False
|
||||||
|
if current_level_id != matched_program.id:
|
||||||
|
_logger.info(
|
||||||
|
'Updating membership level for customer %s (ID: %s): '
|
||||||
|
'%s -> %s (total purchases: %.2f)',
|
||||||
|
partner.name,
|
||||||
|
partner.id,
|
||||||
|
partner.membership_level_id.name if partner.membership_level_id else 'None',
|
||||||
|
matched_program.name,
|
||||||
|
total_purchases,
|
||||||
|
)
|
||||||
|
partner.sudo().write({'membership_level_id': matched_program.id})
|
||||||
|
else:
|
||||||
|
_logger.debug(
|
||||||
|
'Membership level unchanged for customer %s (ID: %s): '
|
||||||
|
'%s (total purchases: %.2f)',
|
||||||
|
partner.name,
|
||||||
|
partner.id,
|
||||||
|
matched_program.name,
|
||||||
|
total_purchases,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Transfer points from old loyalty card(s) to new loyalty card
|
||||||
|
all_multi_level_program_ids = loyalty_programs.mapped('id')
|
||||||
|
other_programs = [pid for pid in all_multi_level_program_ids if pid != matched_program.id]
|
||||||
|
|
||||||
|
if other_programs:
|
||||||
|
old_cards = self.env['loyalty.card'].sudo().search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('program_id', 'in', other_programs),
|
||||||
|
('points', '!=', 0),
|
||||||
|
])
|
||||||
|
|
||||||
|
if old_cards:
|
||||||
|
new_card = self.env['loyalty.card'].sudo().search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('program_id', '=', matched_program.id),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
if not new_card:
|
||||||
|
new_card = self.env['loyalty.card'].sudo().create({
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'program_id': matched_program.id,
|
||||||
|
'points': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
for old_card in old_cards:
|
||||||
|
pts_to_transfer = old_card.points
|
||||||
|
if abs(pts_to_transfer) > 0.0001: # Simple check to avoid floating point noise transfers
|
||||||
|
new_card.points += pts_to_transfer
|
||||||
|
old_card.points = 0
|
||||||
|
_logger.info(
|
||||||
|
'Transferred %s points from loyalty program "%s" to "%s" for customer %s (ID: %s)',
|
||||||
|
pts_to_transfer,
|
||||||
|
old_card.program_id.name,
|
||||||
|
new_card.program_id.name,
|
||||||
|
partner.name,
|
||||||
|
partner.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_customer_total_purchases(self, partner):
|
||||||
|
"""Calculate the total amount of paid/done POS orders for a given partner.
|
||||||
|
|
||||||
|
:param partner: res.partner record
|
||||||
|
:returns: float total of amount_total across all qualifying orders
|
||||||
|
"""
|
||||||
|
orders = self.sudo().search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('state', 'in', ('paid', 'done')),
|
||||||
|
])
|
||||||
|
return sum(orders.mapped('amount_total'))
|
||||||
24
static/src/app/pos_store_patch.js
Normal file
24
static/src/app/pos_store_patch.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
|
||||||
|
import { PosStore } from "@point_of_sale/app/services/pos_store";
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(PosStore.prototype, {
|
||||||
|
async postProcessLoyalty(order) {
|
||||||
|
console.group(`[Loyalty Auto Level] Processing Loyalty for Order: ${order.name}`);
|
||||||
|
const partner = order.getPartner();
|
||||||
|
console.log("Customer:", partner ? partner.name : "No Customer");
|
||||||
|
|
||||||
|
console.log("Coupon Point Changes (Before Sync):", JSON.parse(JSON.stringify(order.uiState.couponPointChanges)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await super.postProcessLoyalty(order);
|
||||||
|
console.log("Loyalty processing completed successfully. The backend confirm_coupon_programs should have triggered point consolidation if a membership upgrade/downgrade occurred.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during loyalty processing:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user