frist commit
This commit is contained in:
commit
f938be25f2
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
.env
|
||||
44
README.md
Normal file
44
README.md
Normal 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
3
__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
25
__manifest__.py
Normal file
25
__manifest__.py
Normal 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
5
models/__init__.py
Normal 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
15
models/pos_config.py
Normal 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
112
models/pos_order.py
Normal 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
36
models/pos_session.py
Normal 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})
|
||||
151
static/src/overrides/models.js
Normal file
151
static/src/overrides/models.js
Normal 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),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user