commit f938be25f22adc7a76c7412e96799d938979a628 Author: Suherdy Yacob Date: Fri Feb 27 17:58:49 2026 +0700 frist commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..beb6b4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.pyc +__pycache__/ +.DS_Store +*.swp +*.swo +*.log +.vscode/ +.idea/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..da707a6 --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..a4e0517 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..363b340 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import pos_order +from . import pos_config +from . import pos_session diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..9b7939e --- /dev/null +++ b/models/pos_config.py @@ -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 diff --git a/models/pos_order.py b/models/pos_order.py new file mode 100644 index 0000000..13ea75e --- /dev/null +++ b/models/pos_order.py @@ -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 + diff --git a/models/pos_session.py b/models/pos_session.py new file mode 100644 index 0000000..96d8f56 --- /dev/null +++ b/models/pos_session.py @@ -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}) diff --git a/static/src/overrides/models.js b/static/src/overrides/models.js new file mode 100644 index 0000000..174a7f5 --- /dev/null +++ b/static/src/overrides/models.js @@ -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), + }); + } + }); + } +});