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