first commit

This commit is contained in:
Suherdy SYC. Yacob 2025-09-02 15:25:52 +07:00
commit 658bb6c72a
12 changed files with 475 additions and 0 deletions

48
README.md Normal file
View File

@ -0,0 +1,48 @@
# POS Order Line Discount
This module converts order-level discounts (like global discount and loyalty program reward discount) to order line discounts in the Point of Sale.
## Features
- Convert order-level discounts to line-level discounts
- Support for loyalty program rewards
- Configurable discount distribution methods
- Maintain proper tax calculations
## Configuration
1. Go to Point of Sale > Configuration > Point of Sale
2. Select your POS configuration
3. In the Pricing section, you'll find:
- **Line Discount Type**: Choose between percentage or fixed amount distribution
- **Apply Line Discount on Rewards**: Enable to convert loyalty rewards to line discounts
## How it works
The module works by intercepting the reward application process and distributing the discount amount across all order lines instead of creating a separate reward line. This ensures that:
1. All discounts are visible at the line level
2. Tax calculations are properly maintained
3. Reporting shows discounts at the line level
## Technical Details
The module patches the following components:
1. **PosOrder model**: Processes rewards as line discounts during order creation
2. **PosConfig model**: Adds configuration options for discount distribution
3. **Order model (JS)**: Overrides reward application to distribute discounts to lines
4. **Orderline model (JS)**: Enhances line discount handling
## Compatibility
This module is compatible with Odoo 17 and requires:
- point_of_sale
- pos_loyalty
## Installation
1. Place the module in your Odoo addons directory
2. Update the apps list
3. Install the "POS Order Line Discount" module
4. Configure your POS settings as needed

3
__init__.py Normal file
View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

23
__manifest__.py Normal file
View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
{
"name": "POS Order Line Discount",
"version": "1.0",
"category": "Point of Sale",
"summary": "Convert order-level discounts to line-level discounts in POS",
"description": """
This module converts order-level discounts (like global discount and loyalty program reward discount)
to order line discounts in the Point of Sale.
""",
"depends": ["point_of_sale", "pos_loyalty"],
"data": [
"views/pos_config_views.xml",
],
"assets": {
"point_of_sale._assets_pos": [
'pos_order_line_discount/static/src/**/*',
],
},
"installable": True,
"auto_install": False,
"license": "LGPL-3"
}

Binary file not shown.

4
models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import pos_order
from . import pos_config

Binary file not shown.

Binary file not shown.

Binary file not shown.

18
models/pos_config.py Normal file
View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class PosConfig(models.Model):
_inherit = 'pos.config'
line_discount_type = fields.Selection([
('percentage', 'Percentage'),
('fixed', 'Fixed Amount')
], string='Line Discount Type', default='percentage',
help="Determines how order-level discounts are distributed to order lines")
apply_line_discount_on_rewards = fields.Boolean(
string="Apply Line Discount on Rewards",
default=True,
help="If checked, loyalty rewards will be applied as line discounts instead of order-level discounts"
)

151
models/pos_order.py Normal file
View File

@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
from odoo import models, api, fields
from odoo.tools import float_compare, float_round
import logging
_logger = logging.getLogger(__name__)
class PosOrder(models.Model):
_inherit = 'pos.order'
def _distribute_discount_to_lines(self, order, discount_amount, is_percentage=False):
"""
Distribute an order-level discount amount to individual order lines
:param order: pos.order record
:param discount_amount: The discount amount to distribute
:param is_percentage: If True, discount_amount is a percentage; otherwise it's a fixed amount
:return: Dictionary with line_id as key and discount value as value
"""
if not order.lines:
return {}
# Filter out reward lines and lines with zero price
regular_lines = order.lines.filtered(lambda l: not l.is_reward_line and l.price_subtotal > 0)
if not regular_lines:
return {}
# Calculate total tax-exclusive amount for proportional distribution
total_amount = sum(line.price_subtotal for line in regular_lines)
if total_amount <= 0:
return {}
line_discounts = {}
if is_percentage:
# Apply the same percentage discount to all regular lines
for line in regular_lines:
# For percentage discounts, we simply add the percentages (simplified approach)
# In a real scenario, you might want to compound them properly
new_discount = min(100, line.discount + discount_amount)
line_discounts[line.id] = new_discount
else:
# Distribute fixed amount proportionally based on line tax-exclusive subtotal
remaining_discount = discount_amount
lines_count = len(regular_lines)
for i, line in enumerate(regular_lines):
if i == lines_count - 1:
# Last line gets the remaining discount to avoid rounding issues
# Calculate the additional discount percentage for this line based on tax-exclusive price
if line.price_subtotal > 0:
additional_discount_percentage = (remaining_discount / line.price_subtotal) * 100
new_discount = min(100, line.discount + additional_discount_percentage)
else:
new_discount = line.discount
line_discounts[line.id] = new_discount
else:
# Calculate proportional discount for this line based on tax-exclusive price
line_ratio = line.price_subtotal / total_amount if total_amount > 0 else 0
line_discount_amount = discount_amount * line_ratio
# Calculate the additional discount percentage for this line based on tax-exclusive price
if line.price_subtotal > 0:
additional_discount_percentage = (line_discount_amount / line.price_subtotal) * 100
new_discount = min(100, line.discount + additional_discount_percentage)
else:
new_discount = line.discount
line_discounts[line.id] = new_discount
remaining_discount -= line_discount_amount
return line_discounts
def _apply_line_discounts(self, order, line_discounts):
"""
Apply calculated discounts to order lines
:param order: pos.order record
:param line_discounts: Dictionary with line_id as key and discount value as value
"""
for line in order.lines:
if line.id in line_discounts:
# Apply the calculated discount
line.discount = line_discounts[line.id]
# Trigger the onchange to recalculate the line amounts
line._onchange_amount_line_all()
@api.model
def _process_order_rewards_as_line_discounts(self, order):
"""
Process loyalty rewards as line discounts instead of order-level discounts
:param order: pos.order record
"""
if not order.config_id.apply_line_discount_on_rewards:
return
# Find reward lines
reward_lines = order.lines.filtered(lambda l: l.is_reward_line)
for reward_line in reward_lines:
reward = reward_line.reward_id
if reward and reward.reward_type == 'discount':
# Calculate the discount amount that should be applied to regular lines
reward_amount = abs(reward_line.price_subtotal)
# Remove the reward line
reward_line.unlink()
# Distribute the discount to regular lines
is_percentage = (reward.discount_mode == 'percent')
discount_value = reward_amount if not is_percentage else reward.discount
line_discounts = self._distribute_discount_to_lines(
order,
discount_value,
is_percentage=is_percentage
)
# Apply the discounts to lines
self._apply_line_discounts(order, line_discounts)
@api.model
def _process_order(self, order, draft, existing_order):
"""
Override to process rewards as line discounts
"""
pos_order_id = super(PosOrder, self)._process_order(order, draft, existing_order)
# Convert order-level rewards to line discounts
if isinstance(pos_order_id, int):
pos_order = self.browse(pos_order_id)
self._process_order_rewards_as_line_discounts(pos_order)
return pos_order_id
class PosOrderLine(models.Model):
_inherit = 'pos.order.line'
def _compute_amount_line_all(self):
"""
Override to handle line-level discounts properly
"""
res = super(PosOrderLine, self)._compute_amount_line_all()
# Additional logic for line discounts can be added here if needed
return res
@api.onchange('discount')
def _onchange_discount(self):
"""
Override to handle line discount changes
"""
super(PosOrderLine, self)._onchange_discount()
# Additional logic for handling line discount changes can be added here

199
static/src/models.js Normal file
View File

@ -0,0 +1,199 @@
/** @odoo-module **/
import { Order, Orderline } from "@point_of_sale/app/store/models";
import { patch } from "@web/core/utils/patch";
// Patch Order methods to handle line discounts
patch(Order.prototype, {
/**
* Override to calculate reward values as line discounts instead of order-level discounts
*/
_getRewardLineValues(args) {
const reward = args["reward"];
const coupon_id = args["coupon_id"];
// If config is not set to apply rewards as line discounts, use the original method
// Note: In JavaScript, we access the config through this.pos.config
if (!this.pos.config.apply_line_discount_on_rewards) {
return super._getRewardLineValues(...arguments);
}
// For discount rewards, we'll distribute the discount to order lines instead of creating reward lines
if (reward.reward_type === "discount") {
// Calculate the reward using the original method first to get the discount amount
const originalRewardLines = super._getRewardLineValues(...arguments);
// Get the total discount amount from the reward lines (tax-excluded)
let totalDiscountAmount = 0;
originalRewardLines.forEach(line => {
// Calculate tax-excluded amount from the reward line
// The line.price is tax-included, so we need to calculate the tax-excluded amount
// Reward lines are plain objects, not Orderline instances, so we calculate manually
let taxExcludedAmount = Math.abs(line.price * line.quantity);
// If the line has tax information, we need to calculate the tax-excluded amount
if (line.tax_ids && line.tax_ids.length > 0) {
// Calculate total tax rate (simplified approach for multiple taxes)
let totalTaxRate = 0;
for (const taxId of line.tax_ids) {
const tax = this.pos.taxes_by_id[taxId];
if (tax && tax.amount_type === 'percent') {
totalTaxRate += tax.amount / 100;
}
}
if (totalTaxRate > 0) {
taxExcludedAmount = Math.abs(line.price * line.quantity) / (1 + totalTaxRate);
}
}
totalDiscountAmount += taxExcludedAmount;
});
// Distribute the discount to order lines instead of creating reward lines
// Filter out reward lines by checking if they have a reward_id property
const orderLines = this.get_orderlines().filter(line => !line.is_reward_line);
const totalOrderAmount = orderLines.reduce((sum, line) => sum + line.get_price_without_tax(), 0);
if (totalOrderAmount > 0 && totalDiscountAmount > 0) {
// Apply the discount to each line proportionally based on its contribution to the total
orderLines.forEach(line => {
// Calculate the line's share of the total discount
const lineRatio = line.get_price_without_tax() / totalOrderAmount;
const lineDiscountAmount = totalDiscountAmount * lineRatio;
// Calculate the discount percentage for this line
// We want to set a discount that results in the lineDiscountAmount reduction
if (line.get_price_without_tax() > 0) {
const lineDiscountPercentage = (lineDiscountAmount / line.get_price_without_tax()) * 100;
// Set the discount percentage for this line (same as %Disc button)
// Ensure the discount percentage is within valid range [0, 100]
const validDiscountPercentage = Math.min(100, Math.max(0, lineDiscountPercentage));
// Mark this line as having a reward discount applied
if (line.originalDiscount === undefined) {
line.originalDiscount = line.discount;
}
// Apply the new discount on top of the original
line.discount = line.originalDiscount + validDiscountPercentage;
}
});
}
// Return empty array since we're not creating reward lines
return [];
}
// For non-discount rewards, use the original method
return super._getRewardLineValues(...arguments);
},
/**
* Distribute an order-level discount to individual lines
*/
_distributeDiscountToLines(discountAmount, isPercentage = false) {
const lines = this.get_orderlines().filter(line => !line.reward_id);
if (lines.length === 0) return;
// Calculate total amount for proportional distribution
const totalAmount = lines.reduce((sum, line) => sum + line.get_price_without_tax(), 0);
if (totalAmount <= 0) return;
if (isPercentage) {
// Apply the same percentage discount to all lines
lines.forEach(line => {
const currentDiscount = line.get_discount();
// Combine discounts (this is a simplification)
// Store the original discount if not already stored
if (line.originalDiscount === undefined) {
line.originalDiscount = line.discount;
}
const newDiscount = Math.min(100, line.originalDiscount + discountAmount);
line.discount = newDiscount;
});
} else {
// Distribute fixed amount proportionally based on line subtotal
lines.forEach(line => {
const lineRatio = line.get_price_without_tax() / totalAmount;
const lineDiscountAmount = discountAmount * lineRatio;
// Calculate the additional discount percentage for this line
if (line.get_price_without_tax() > 0) {
const additionalDiscountPercentage = (lineDiscountAmount / line.get_price_without_tax()) * 100;
// Store the original discount if not already stored
if (line.originalDiscount === undefined) {
line.originalDiscount = line.discount;
}
const newDiscountPercentage = Math.min(100, line.originalDiscount + additionalDiscountPercentage);
line.discount = newDiscountPercentage;
}
});
}
},
/**
* Apply a global discount to all order lines
*/
setGlobalDiscount(discountPercentage) {
this.get_orderlines().forEach(line => {
if (!line.is_reward_line) {
// Store the original discount if not already stored
if (line.originalDiscount === undefined) {
line.originalDiscount = line.discount;
}
line.discount = discountPercentage;
}
});
},
/**
* Override to also reset line discounts applied by this module
*/
_resetPrograms() {
// First call the original reset method
super._resetPrograms(...arguments);
// Reset line discounts that were applied by this module
this.get_orderlines().forEach(line => {
if (!line.is_reward_line && line.originalDiscount !== undefined) {
// Reset to the original discount value
line.discount = line.originalDiscount;
delete line.originalDiscount;
}
});
},
/**
* Clear all line discounts applied by this module
*/
clearLineDiscounts() {
this.get_orderlines().forEach(line => {
if (!line.is_reward_line && line.originalDiscount !== undefined) {
// Reset to the original discount value
line.discount = line.originalDiscount;
delete line.originalDiscount;
}
});
}
});
// Patch Orderline methods to handle line discounts
patch(Orderline.prototype, {
/**
* Override to handle line discount changes
*/
set_discount(discount) {
// Call the original method
super.set_discount(...arguments);
// Store the original discount if not already stored and this is not a reward line
if (this.originalDiscount === undefined && !this.is_reward_line) {
this.originalDiscount = this.discount;
}
},
/**
* Get the discount amount for this line
*/
get_discount_amount() {
return this.get_price_without_tax() * (this.get_discount() / 100);
}
});

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_pos_config_form_line_discount" model="ir.ui.view">
<field name="name">pos.config.form.line.discount</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="//sheet" position="inside">
<div class="row mt16">
<div class="col-12 col-lg-12" id="line_discount_options">
<h2>Line Discount Options</h2>
<div class="row">
<div class="col-6 col-lg-6">
<label for="line_discount_type" string="Line Discount Type"/>
<field name="line_discount_type"/>
</div>
<div class="col-6 col-lg-6">
<label for="apply_line_discount_on_rewards" string="Apply Line Discount on Rewards"/>
<field name="apply_line_discount_on_rewards"/>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</data>
</odoo>