frist commit

This commit is contained in:
Suherdy Yacob 2026-02-27 17:58:49 +07:00
commit f938be25f2
9 changed files with 400 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
*.pyc
__pycache__/
.DS_Store
*.swp
*.swo
*.log
.vscode/
.idea/
.env

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# POS Reverse Down Payment
This custom Odoo 17 module implements a special workflow for handling down payments in the Point of Sale (POS) where the cashier creates a sales quotation from the POS UI but leaves the standard products in the cart for printing on the receipt.
## Purpose
The module resolves a few standard Odoo limitations for specific workflows:
1. **Zero-Priced Quotation Products:** Customers can receive a physical POS receipt displaying all ordered standard items (at 0 price) and a "Down Payment" line at normal price.
2. **Quotation Generation:** A backend Quotation (`sale.order`) is automatically created via XML-RPC. Standard products are pushed onto this Quotation with their original prices and taxes.
3. **No Stock Deductions:** Since the standard products remain in the POS cart at $0 price, Odoo normally tries to create zero-value stock moves. This module intercepts these moves (using `is_quotation_line` flag) to ensure stock doesn't get erroneously deducted before the Quotation is confirmed by backend staff.
4. **Kitchen Order Prevention:** Quotation items are prevented from printing in the kitchen display by overriding the `sendChanges` logic (for `pos_preparation_display` and `pos_restaurant`).
5. **Settlement Stability:** Properly resolves Odoo's internal UI when the cashier eventually loads the Quotation and settles the remaining balance.
## Dependencies
- Odoo 17.0
- `point_of_sale`
- `pos_sale`
- `pos_restaurant`
- `pos_preparation_display`
## How it works (Technical Flow)
### 1. POS UI Payment Intercept
When processing a payment (`models.js` `pay()` override), the module checks if:
- Standard products exist.
- A down payment product exists with a positive quantity and price.
If true, it asks the cashier if they want to create a quotation.
### 2. Backend RPC Creation
If approved, it sends the original list of standard products via `create_quotation_from_pos_lines` in `pos_order.py`.
The newly created `sale_order_origin_id` is linked to the down payment line to ensure tracking and eventual invoice settlements match.
### 3. POS Clean Up
The cart's standard items are zero-priced and flagged (`is_quotation_line = true`) so the custom JS won't prompt again during final payment validation.
### 4. Printing & Preparation
- **Kitchen Tickets**: Checks for positive down payments in `sendChanges`. If found, no items go to the kitchen.
- **Stock Moves**: Odoo's `_create_order_picking` natively ignores lines with `is_quotation_line`.
## Installation
Clone or copy this repository into your Odoo custom addons path, update the app list, and install the `pos_reverse_downpayment` module.
No further configurations are required besides the standard Odoo POS Down Payment product settings.

3
__init__.py Normal file
View File

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

25
__manifest__.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
{
'name': 'POS Reverse Downpayment',
'version': '1.0',
'category': 'Sales/Point of Sale',
'summary': 'Create Quotation from POS Cart and Zero-out Standard Items',
'description': """
This module allows cashiers to:
- Add standard items and a downpayment product to the POS cart
- When paying, convert the cart to a backend Quotation
- Keep standard items visible on POS receipt with price zeroed out
- Prevent stock moves for zero-priced standard items
""",
'depends': ['pos_sale', 'pos_restaurant'],
'data': [],
'assets': {
'point_of_sale._assets_pos': [
'pos_reverse_downpayment/static/src/overrides/models.js',
],
},
'installable': True,
'auto_install': False,
'application': False,
'license': 'LGPL-3',
}

5
models/__init__.py Normal file
View File

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

15
models/pos_config.py Normal file
View File

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from odoo import models
class PosConfig(models.Model):
_inherit = 'pos.config'
def _get_special_products(self):
"""
Remove down payment products from the special products list
so they can be manually selected in the POS UI.
"""
res = super()._get_special_products()
down_payment_products = self.env['pos.config'].search([]).mapped('down_payment_product_id')
return res - down_payment_products

112
models/pos_order.py Normal file
View File

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class PosOrder(models.Model):
_inherit = 'pos.order'
@api.model
def create_quotation_from_pos_lines(self, partner_id, lines_data, config_id):
"""
Creates a new sale.order (Quotation) from standard POS lines,
leaving out the downpayment product.
:param partner_id: int, ID of the partner for the Quotation
:param lines_data: list of dicts, details of POS lines
:param config_id: int, ID of pos.config to get the downpayment product
:return: dict with 'id' and 'name' of the created sale.order
"""
try:
pos_config = self.env['pos.config'].browse(config_id)
down_payment_product_id = pos_config.down_payment_product_id.id
if not partner_id:
return False
sale_order = self.env['sale.order'].create({
'partner_id': partner_id,
})
# Add lines to the sale.order
for line in lines_data:
if line['product_id'] != down_payment_product_id:
# Prepare tax IDs
tax_ids = line.get('tax_ids', [])
if isinstance(tax_ids, list) and not tax_ids:
tax_ids = []
product = self.env['product.product'].browse(line['product_id'])
self.env['sale.order.line'].create({
'order_id': sale_order.id,
'product_id': product.id,
'name': product.display_name or product.name,
'product_uom_qty': line['qty'],
'price_unit': line['price_unit'],
'discount': line.get('discount', 0.0),
'tax_id': [(6, 0, tax_ids)],
})
return {
'id': sale_order.id,
'name': sale_order.name,
}
except Exception as e:
_logger.error("Error creating quotation from POS lines: %s", repr(e), exc_info=True)
raise e
def _create_order_picking(self):
self.ensure_one()
if self.shipping_date:
self.sudo().lines.filtered(lambda l: not l.is_quotation_line)._launch_stock_rule_from_pos_order_lines()
else:
if self._should_create_picking_real_time():
picking_type = self.config_id.picking_type_id
if self.partner_id.property_stock_customer:
destination_id = self.partner_id.property_stock_customer.id
elif not picking_type or not picking_type.default_location_dest_id:
destination_id = self.env['stock.warehouse']._get_partner_locations()[0].id
else:
destination_id = picking_type.default_location_dest_id.id
lines_to_pick = self.lines.filtered(lambda l: not l.is_quotation_line)
if lines_to_pick:
pickings = self.env['stock.picking']._create_picking_from_pos_order_lines(destination_id, lines_to_pick, picking_type, self.partner_id)
pickings.write({'pos_session_id': self.session_id.id, 'pos_order_id': self.id, 'origin': self.name})
def _process_preparation_changes(self, cancelled=False, note_history=None):
"""
Intercepts preparation display generation. If this POS order is actually a Quotation
we don't want it to be processed by the kitchen display.
"""
if any(line.is_quotation_line for line in self.lines):
return {'change': False, 'sound': False, 'category_ids': []}
# Check if pos_preparation_display is installed and we can call super
if hasattr(super(), '_process_preparation_changes'):
return super()._process_preparation_changes(cancelled=cancelled, note_history=note_history)
return {'change': False, 'sound': False, 'category_ids': []}
class PosOrderLine(models.Model):
_inherit = 'pos.order.line'
is_quotation_line = fields.Boolean(
string='Is Quotation Line',
help='Indicates this line was converted to a quotation and its price was zeroed out.',
default=False
)
def _export_for_ui(self, orderline):
result = super()._export_for_ui(orderline)
result['is_quotation_line'] = orderline.is_quotation_line
return result
def _order_line_fields(self, line, session_id=None):
result = super()._order_line_fields(line, session_id)
if 'is_quotation_line' in line[2]:
result[2]['is_quotation_line'] = line[2]['is_quotation_line']
return result

36
models/pos_session.py Normal file
View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from odoo import models
class PosSession(models.Model):
_inherit = 'pos.session'
def _create_picking_at_end_of_session(self):
self.ensure_one()
lines_grouped_by_dest_location = {}
picking_type = self.config_id.picking_type_id
if not picking_type or not picking_type.default_location_dest_id:
session_destination_id = self.env['stock.warehouse']._get_partner_locations()[0].id
else:
session_destination_id = picking_type.default_location_dest_id.id
for order in self._get_closed_orders():
if order.company_id.anglo_saxon_accounting and order.is_invoiced or order.shipping_date:
continue
destination_id = order.partner_id.property_stock_customer.id or session_destination_id
# Filter out lines that were converted to Quotation
valid_lines = order.lines.filtered(lambda l: not l.is_quotation_line)
if not valid_lines:
continue
if destination_id in lines_grouped_by_dest_location:
lines_grouped_by_dest_location[destination_id] |= valid_lines
else:
lines_grouped_by_dest_location[destination_id] = valid_lines
for location_dest_id, lines in lines_grouped_by_dest_location.items():
pickings = self.env['stock.picking']._create_picking_from_pos_order_lines(location_dest_id, lines, picking_type)
pickings.write({'pos_session_id': self.id, 'origin': self.name})

View File

@ -0,0 +1,151 @@
/** @odoo-module */
import { Order, Orderline } from "@point_of_sale/app/store/models";
import { patch } from "@web/core/utils/patch";
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup";
import { _t } from "@web/core/l10n/translation";
import { OrderReceipt } from "@point_of_sale/app/screens/receipt_screen/receipt/order_receipt";
import { onError } from "@odoo/owl";
patch(Order.prototype, {
setup() {
super.setup(...arguments);
},
async pay() {
// Check if down payment product is in the cart
const downPaymentProductId = this.pos.config.down_payment_product_id && this.pos.config.down_payment_product_id[0];
if (!downPaymentProductId) {
return super.pay(...arguments);
}
const lines = this.get_orderlines();
// Only consider it a "quotation creation" downpayment if it has a positive quantity and price.
// If pos_sale adds it to deduct a downpayment, it will use negative qty or subtotal.
const downPaymentLines = lines.filter(line =>
line.product.id === downPaymentProductId &&
line.get_quantity() > 0 &&
line.get_unit_price() >= 0
);
const standardLines = lines.filter(line => line.product.id !== downPaymentProductId);
// If there is a down payment AND standard products that haven't been zeroed out yet
// we prompt the user to create a quotation.
const needsQuotation = downPaymentLines.length > 0 && standardLines.some(line => line.price > 0 || line.get_unit_price() > 0);
if (needsQuotation && !this.is_quotation_line_converted) {
if (!this.get_partner()) {
await this.env.services.popup.add(ErrorPopup, {
title: _t("Customer Required"),
body: _t("Please select a customer before creating a quotation with a down payment."),
});
return;
}
const { confirmed } = await this.env.services.popup.add(ConfirmPopup, {
title: _t("Create Quotation?"),
body: _t("This cart contains a down payment and standard products. Do you want to create a backend Quotation for the standard products and proceed to pay only the down payment?"),
confirmText: _t("Yes"),
cancelText: _t("No")
});
if (confirmed) {
try {
// Prepare data for backend
const linesData = standardLines.map(line => ({
product_id: line.product.id,
qty: line.get_quantity(),
price_unit: line.get_unit_price(),
discount: line.get_discount(),
tax_ids: line.tax_ids || line.get_product().taxes_id || [],
}));
// Call backend to create sale.order (removed the first empty array for @api.model)
const saleOrderResult = await this.env.services.orm.call(
"pos.order",
"create_quotation_from_pos_lines",
[this.get_partner().id, linesData, this.pos.config.id]
);
if (saleOrderResult) {
// Link the down payment line to the newly created sale order
const dpLine = lines.find(line => line.product.id === downPaymentProductId);
if (dpLine) {
dpLine.sale_order_origin_id = {
id: saleOrderResult.id,
name: saleOrderResult.name,
};
}
// Zero out the standard lines to not charge the customer twice
for (const line of standardLines) {
line.set_unit_price(0);
// Set a flag to easily skip stock delivery later if needed
line.is_quotation_line = true;
}
this.is_quotation_line_converted = true;
} else {
await this.env.services.popup.add(ErrorPopup, {
title: _t("Error"),
body: _t("Failed to create the quotation."),
});
return;
}
} catch (error) {
await this.env.services.popup.add(ErrorPopup, {
title: _t("Error"),
body: _t("An error occurred while creating the quotation: ") + error.message,
});
return;
}
} else {
return; // User cancelled
}
}
return super.pay(...arguments);
},
// Override preparation display / kitchen printing
// If we have a down payment, we consider this a quote, not an immediate order for the kitchen
async sendChanges(cancelled) {
const downPaymentProductId = this.pos.config.down_payment_product_id && this.pos.config.down_payment_product_id[0];
// Only block if there's a positive down payment line (indicating quotation creation, not settlement)
if (downPaymentProductId && this.get_orderlines().some(line => line.product.id === downPaymentProductId && line.get_quantity() > 0 && line.get_unit_price() > 0)) {
// It's a quotation line, we don't send it to preparation display
this.noteHistory = {};
this.orderlines.forEach(line => line.setHasChange(false));
return true;
}
return super.sendChanges(...arguments);
}
});
patch(Orderline.prototype, {
init_from_JSON(json) {
super.init_from_JSON(...arguments);
this.is_quotation_line = json.is_quotation_line || false;
},
export_as_JSON() {
const json = super.export_as_JSON(...arguments);
json.is_quotation_line = this.is_quotation_line || false;
return json;
}
});
patch(OrderReceipt.prototype, {
setup() {
super.setup(...arguments);
onError((error) => {
console.error("OrderReceipt Render Error:", error);
if (this.env && this.env.services && this.env.services.popup) {
this.env.services.popup.add(ErrorPopup, {
title: "Receipt Render Error",
body: "The receipt failed to render: " + (error.message || error),
});
}
});
}
});