From f8f4e899b51a73b48882b2b6e33441f0e8eed477 Mon Sep 17 00:00:00 2001 From: "Adrien Guilliams (adgu)" Date: Wed, 8 Feb 2023 15:04:57 +0000 Subject: [PATCH] [IMP] point_of_sale: fields added to X and Z reports The X report of a PoS is a resume of the sales/refunds and other information of the session. It can be printed at any moment of the session. The Z report is the same as the X report with a bit more information like the starting and the ending datetime of the session. It can only be printed after the end of a session. The Z report can only be printed with the Swedish and Belgian localization at the moment and the goal is to make it available in the standard version. This commit also adds some information to the reports like the different invoices done during the session and also changes the layout of the reports to be more readable. closes odoo/odoo#113228 X-original-commit: b8b9067642fd75c2c02b202d9cffac98a4e150d4 Signed-off-by: Heinz Robin (rhe) --- addons/pos_daily_sales_reports/__init__.py | 2 + .../pos_daily_sales_reports/__manifest__.py | 26 ++ .../models/__init__.py | 1 + .../models/pos_daily_sales_reports.py | 370 +++++++++++++++ .../security/ir.model.access.csv | 2 + .../views/point_of_sale_view.xml | 424 ++++++++++++++++++ .../wizard/__init__.py | 1 + .../wizard/pos_daily_sales_reports_wizard.py | 15 + .../wizard/pos_daily_sales_reports_wizard.xml | 24 + 9 files changed, 865 insertions(+) create mode 100644 addons/pos_daily_sales_reports/__init__.py create mode 100644 addons/pos_daily_sales_reports/__manifest__.py create mode 100644 addons/pos_daily_sales_reports/models/__init__.py create mode 100644 addons/pos_daily_sales_reports/models/pos_daily_sales_reports.py create mode 100644 addons/pos_daily_sales_reports/security/ir.model.access.csv create mode 100644 addons/pos_daily_sales_reports/views/point_of_sale_view.xml create mode 100644 addons/pos_daily_sales_reports/wizard/__init__.py create mode 100644 addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.py create mode 100644 addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.xml diff --git a/addons/pos_daily_sales_reports/__init__.py b/addons/pos_daily_sales_reports/__init__.py new file mode 100644 index 00000000000..9b4296142f4 --- /dev/null +++ b/addons/pos_daily_sales_reports/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/addons/pos_daily_sales_reports/__manifest__.py b/addons/pos_daily_sales_reports/__manifest__.py new file mode 100644 index 00000000000..cf6a00065a1 --- /dev/null +++ b/addons/pos_daily_sales_reports/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + 'name': 'Point of Sale Daily Sales Reports', + 'version': '1.0', + 'category': 'Point of Sale', + 'sequence': 6, + 'summary': 'Daily X and Z sales reports of a Point of Sale session', + 'description': """ + +This module allows the cashier to quickly print a X and a Z sale report +for a given session or a Sales Details for multiple sessions +and configs. + +""", + 'depends': ['point_of_sale'], + 'data': [ + 'security/ir.model.access.csv', + 'wizard/pos_daily_sales_reports_wizard.xml', + 'views/point_of_sale_view.xml', + ], + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/addons/pos_daily_sales_reports/models/__init__.py b/addons/pos_daily_sales_reports/models/__init__.py new file mode 100644 index 00000000000..09377ccb0b1 --- /dev/null +++ b/addons/pos_daily_sales_reports/models/__init__.py @@ -0,0 +1 @@ +from . import pos_daily_sales_reports diff --git a/addons/pos_daily_sales_reports/models/pos_daily_sales_reports.py b/addons/pos_daily_sales_reports/models/pos_daily_sales_reports.py new file mode 100644 index 00000000000..2a3cd9cbaa7 --- /dev/null +++ b/addons/pos_daily_sales_reports/models/pos_daily_sales_reports.py @@ -0,0 +1,370 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from odoo.osv.expression import AND +from datetime import timedelta + +import pytz + + +class ReportSaleDetails(models.AbstractModel): + + _inherit = 'report.point_of_sale.report_saledetails' + + @api.model + def get_sale_details(self, date_start=False, date_stop=False, config_ids=False, session_ids=False): + """ Serialise the orders of the requested time period, configs and sessions. + :param date_start: The dateTime to start, default today 00:00:00. + :type date_start: str. + :param date_stop: The dateTime to stop, default date_start + 23:59:59. + :type date_stop: str. + :param config_ids: Pos Config id's to include. + :type config_ids: list of numbers. + :param session_ids: Pos Config id's to include. + :type session_ids: list of numbers. + :returns: dict -- Serialised sales. + """ + #This function is overriding the get_sale_details function in point_of_sale module + #It will be removed soon after the forwardport of the improvment of the X and Z reports. + domain = [('state', 'in', ['paid', 'invoiced', 'done'])] + if (session_ids): + domain = AND([domain, [('session_id', 'in', session_ids)]]) + else: + if date_start: + date_start = fields.Datetime.from_string(date_start) + else: + # start by default today 00:00:00 + user_tz = pytz.timezone(self.env.context.get('tz') or self.env.user.tz or 'UTC') + today = user_tz.localize(fields.Datetime.from_string(fields.Date.context_today(self))) + date_start = today.astimezone(pytz.timezone('UTC')) + + if date_stop: + date_stop = fields.Datetime.from_string(date_stop) + # avoid a date_stop smaller than date_start + if (date_stop < date_start): + date_stop = date_start + timedelta(days=1, seconds=-1) + else: + # stop by default today 23:59:59 + date_stop = date_start + timedelta(days=1, seconds=-1) + + domain = AND([domain, + [('date_order', '>=', fields.Datetime.to_string(date_start)), + ('date_order', '<=', fields.Datetime.to_string(date_stop))] + ]) + + if config_ids: + domain = AND([domain, [('config_id', 'in', config_ids)]]) + + orders = self.env['pos.order'].search(domain) + + user_currency = self.env.company.currency_id + + total = 0.0 + products_sold = {} + taxes = {} + refund_done = {} + refund_taxes = {} + for order in orders: + if user_currency != order.pricelist_id.currency_id: + total += order.pricelist_id.currency_id._convert( + order.amount_total, user_currency, order.company_id, order.date_order or fields.Date.today()) + else: + total += order.amount_total + currency = order.session_id.currency_id + + for line in order.lines: + if line.qty >= 0: + products_sold, taxes = self._get_products_and_taxes_dict(line, products_sold, taxes, currency) + else: + refund_done, refund_taxes = self._get_products_and_taxes_dict(line, refund_done, refund_taxes, currency) + + payment_ids = self.env["pos.payment"].search([('pos_order_id', 'in', orders.ids)]).ids + if payment_ids: + self.env.cr.execute(""" + SELECT method.id as id, payment.session_id as session, COALESCE(method.name->>%s, method.name->>'en_US') as name, method.is_cash_count as cash, + sum(amount) total, method.journal_id journal_id + FROM pos_payment AS payment, + pos_payment_method AS method + WHERE payment.payment_method_id = method.id + AND payment.id IN %s + GROUP BY method.name, method.is_cash_count, payment.session_id, method.id, journal_id + """, (self.env.lang, tuple(payment_ids),)) + payments = self.env.cr.dictfetchall() + else: + payments = [] + + configs = [] + sessions = [] + if config_ids: + configs = self.env['pos.config'].search([('id', 'in', config_ids)]) + if session_ids: + sessions = self.env['pos.session'].search([('id', 'in', session_ids)]) + else: + sessions = self.env['pos.session'].search([('config_id', 'in', configs.ids), ('start_at', '>=', date_start), ('stop_at', '<=', date_stop)]) + else: + sessions = self.env['pos.session'].search([('id', 'in', session_ids)]) + for session in sessions: + configs.append(session.config_id) + + for payment in payments: + payment['count'] = False + + for session in sessions: + cash_counted = 0 + if session.cash_register_balance_end_real: + cash_counted = session.cash_register_balance_end_real + + for payment in payments: + account_payments = self.env['account.payment'].search([('pos_session_id', '=', session.id)]) + if payment['session'] == session.id: + if not payment['cash']: + for account_payment in account_payments: + if payment['id'] == account_payment.pos_payment_method_id.id: + payment['final_count'] = payment['total'] + payment['money_counted'] = account_payment.amount + payment['money_difference'] = payment['money_counted'] - payment['final_count'] + payment['cash_moves'] = [] + if payment['money_difference'] > 0: + move_name = 'Difference observed during the counting (Profit)' + payment['cash_moves'] = [{'name': move_name, 'amount': payment['money_difference']}] + elif payment['money_difference'] < 0: + move_name = 'Difference observed during the counting (Loss)' + payment['cash_moves'] = [{'name': move_name, 'amount': payment['money_difference']}] + payment['count'] = True + break + else: + payment['final_count'] = payment['total'] + session.cash_register_balance_start + session.cash_real_transaction + payment['money_counted'] = cash_counted + payment['money_difference'] = payment['money_counted'] - payment['final_count'] + cash_moves = self.env['account.bank.statement.line'].search([('pos_session_id', '=', session.id)]) + cash_in_out_list = [] + cash_in_count = 0 + cash_out_count = 0 + if session.cash_register_balance_start > 0: + cash_in_out_list.append({ + 'name': 'Cash Opening', + 'amount': session.cash_register_balance_start, + }) + for cash_move in cash_moves: + if cash_move.amount > 0: + cash_in_count += 1 + name = f'Cash in {cash_in_count}' + else: + cash_out_count += 1 + name = f'Cash out {cash_out_count}' + if cash_move.move_id.journal_id.id == payment['journal_id']: + cash_in_out_list.append({ + 'name': cash_move.payment_ref if cash_move.payment_ref else name, + 'amount': cash_move.amount + }) + payment['cash_moves'] = cash_in_out_list + payment['count'] = True + products = [] + refund_products = [] + for category_name, product_list in products_sold.items(): + category_dictionnary = { + 'name': category_name, + 'products': sorted([{ + 'product_id': product.id, + 'product_name': product.name, + 'code': product.default_code, + 'quantity': qty, + 'price_unit': price_unit, + 'discount': discount, + 'uom': product.uom_id.name + } for (product, price_unit, discount), qty in product_list.items()], key=lambda l: l['product_name']), + } + products.append(category_dictionnary) + products = sorted(products, key=lambda l: str(l['name'])) + + for category_name, product_list in refund_done.items(): + category_dictionnary = { + 'name': category_name, + 'products': sorted([{ + 'product_id': product.id, + 'product_name': product.name, + 'code': product.default_code, + 'quantity': qty, + 'price_unit': price_unit, + 'discount': discount, + 'uom': product.uom_id.name + } for (product, price_unit, discount), qty in product_list.items()], key=lambda l: l['product_name']), + } + refund_products.append(category_dictionnary) + refund_products = sorted(refund_products, key=lambda l: str(l['name'])) + + products, products_info = self._get_total_and_qty_per_category(products) + refund_products, refund_info = self._get_total_and_qty_per_category(refund_products) + + currency = { + 'symbol': user_currency.symbol, + 'position': True if user_currency.position == 'after' else False, + 'total_paid': user_currency.round(total), + 'precision': user_currency.decimal_places, + } + + session_name = False + if len(sessions) == 1: + state = sessions[0].state + date_start = sessions[0].start_at + date_stop = sessions[0].stop_at + session_name = sessions[0].name + else: + state = "multiple" + + config_names = [] + for config in configs: + config_names.append(config.name) + + discount_number = 0 + discount_amount = 0 + invoiceList = [] + invoiceTotal = 0 + for session in sessions: + discount_number += len(session.order_ids.filtered(lambda o: o.lines.filtered(lambda l: l.discount > 0))) + discount_amount += session.get_total_discount() + invoiceList.append({ + 'name': session.name, + 'invoices': session._get_invoice_total_list() + }) + invoiceTotal += session._get_total_invoice() + + return { + 'opening_note': sessions[0].opening_notes if len(sessions) == 1 else False, + 'closing_note': sessions[0].closing_notes if len(sessions) == 1 else False, + 'state': state, + 'currency': currency, + 'nbr_orders': len(orders), + 'date_start': date_start, + 'date_stop': date_stop, + 'session_name': session_name if session_name else False, + 'config_names': config_names, + 'payments': payments, + 'company_name': self.env.company.name, + 'taxes': list(taxes.values()), + 'products': products, + 'products_info': products_info, + 'refund_taxes': list(refund_taxes.values()), + 'refund_info': refund_info, + 'refund_products': refund_products, + 'discount_number': discount_number, + 'discount_amount': discount_amount, + 'invoiceList': invoiceList, + 'invoiceTotal': invoiceTotal, + } + + def _get_products_and_taxes_dict(self, line, products, taxes, currency): + key1 = line.product_id.product_tmpl_id.pos_categ_id.name + key2 = (line.product_id, line.price_unit, line.discount) + products.setdefault(key1, {}) + products[key1].setdefault(key2, 0.0) + products[key1][key2] += line.qty + + if line.tax_ids_after_fiscal_position: + line_taxes = line.tax_ids_after_fiscal_position.sudo().compute_all(line.price_unit * (1-(line.discount or 0.0)/100.0), currency, line.qty, product=line.product_id, partner=line.order_id.partner_id or False) + for tax in line_taxes['taxes']: + taxes.setdefault(tax['id'], {'name': tax['name'], 'tax_amount':0.0, 'base_amount':0.0}) + taxes[tax['id']]['tax_amount'] += tax['amount'] + taxes[tax['id']]['base_amount'] += tax['base'] + else: + taxes.setdefault(0, {'name': _('No Taxes'), 'tax_amount':0.0, 'base_amount':0.0}) + taxes[0]['base_amount'] += line.price_subtotal_incl + + return products, taxes + + def _get_total_and_qty_per_category(self, categories): + all_qty = 0 + all_total = 0 + for category_dict in categories: + qty_cat = 0 + total_cat = 0 + for product in category_dict['products']: + qty_cat += product['quantity'] + total_cat += (product['quantity'] * product['price_unit']) * (100 - product['discount']) / 100 + product['total_paid'] = (product['quantity'] * product['price_unit']) * (100 - product['discount']) / 100 + category_dict['total'] = total_cat + category_dict['qty'] = qty_cat + all_qty += qty_cat + all_total += total_cat + + return categories, {'total': all_total, 'qty': all_qty} + + class PosSession(models.Model): + + _inherit = 'pos.session' + + closing_notes = fields.Text(string='Closing Notes') + + def update_closing_control_state_session(self, notes): + # Prevent closing the session again if it was already closed + if self.state == 'closed': + raise UserError(_('This session is already closed.')) + # Prevent the session to be opened again. + self.write({'state': 'closing_control', 'stop_at': fields.Datetime.now(), 'closing_notes': notes}) + self._post_cash_details_message('Closing', self.cash_register_difference, notes) + + def get_total_discount(self): + amount = 0 + for line in self.env['pos.order.line'].search([('order_id', 'in', self.order_ids.ids), ('discount', '>', 0)]): + normal_price = line.qty * line.price_unit + normal_price = normal_price + (normal_price / 100 * line.tax_ids.amount) + amount += normal_price - line.price_subtotal_incl + + return amount + + def _get_invoice_total_list(self): + invoice_list = [] + for order in self.order_ids.filtered(lambda o: o.is_invoiced): + invoice = { + 'total': order.account_move.amount_total, + 'name': order.account_move.highest_name, + 'order_ref': order.pos_reference + } + invoice_list.append(invoice) + + return invoice_list + + def _get_total_invoice(self): + amount = 0 + for order in self.order_ids.filtered(lambda o: o.is_invoiced): + amount += order.amount_paid + + return amount + + def get_total_sold_refund_per_category(self, group_by_user_id=None): + total_sold_per_user_per_category = {} + total_refund_per_user_per_category = {} + + for order in self.order_ids: + if group_by_user_id: + user_id = order.user_id.id + else: + # use a user_id of 0 to keep the logic between with user group and without user group the same + user_id = 0 + + if user_id not in total_sold_per_user_per_category: + total_sold_per_user_per_category[user_id] = {} + total_refund_per_user_per_category[user_id] = {} + + total_sold_per_category = total_sold_per_user_per_category[user_id] + total_refund_per_category = total_refund_per_user_per_category[user_id] + + for line in order.lines: + key = line.product_id.pos_categ_id.name or "None" + if line.qty >= 0: + if key in total_sold_per_category: + total_sold_per_category[key] += line.price_subtotal_incl + else: + total_sold_per_category[key] = line.price_subtotal_incl + else: + if key in total_refund_per_category: + total_refund_per_category[key] += line.price_subtotal_incl + else: + total_refund_per_category[key] = line.price_subtotal_incl + + if group_by_user_id or not total_sold_per_user_per_category: + return list(total_sold_per_user_per_category.items()), list(total_refund_per_user_per_category.items()) + else: + return list(total_sold_per_user_per_category[0].items()), list(total_refund_per_user_per_category[0].items()) diff --git a/addons/pos_daily_sales_reports/security/ir.model.access.csv b/addons/pos_daily_sales_reports/security/ir.model.access.csv new file mode 100644 index 00000000000..14d762c446d --- /dev/null +++ b/addons/pos_daily_sales_reports/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_pos_daily_sales_reports_wizard,access.pos.daily.sales.reports.wizard,model_pos_daily_sales_reports_wizard,point_of_sale.group_pos_manager,1,1,1,0 diff --git a/addons/pos_daily_sales_reports/views/point_of_sale_view.xml b/addons/pos_daily_sales_reports/views/point_of_sale_view.xml new file mode 100644 index 00000000000..556b7f20666 --- /dev/null +++ b/addons/pos_daily_sales_reports/views/point_of_sale_view.xml @@ -0,0 +1,424 @@ + + + + + + diff --git a/addons/pos_daily_sales_reports/wizard/__init__.py b/addons/pos_daily_sales_reports/wizard/__init__.py new file mode 100644 index 00000000000..c86130c30fa --- /dev/null +++ b/addons/pos_daily_sales_reports/wizard/__init__.py @@ -0,0 +1 @@ +from . import pos_daily_sales_reports_wizard diff --git a/addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.py b/addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.py new file mode 100644 index 00000000000..577406679fb --- /dev/null +++ b/addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class PosDailyReport(models.TransientModel): + _name = 'pos.daily.sales.reports.wizard' + _description = 'Point of Sale Daily Report' + + pos_session_id = fields.Many2one('pos.session') + + def generate_report(self): + data = {'date_start': False, 'date_stop': False, 'config_ids': self.pos_session_id.config_id.ids, 'session_ids': self.pos_session_id.ids} + return self.env.ref('point_of_sale.sale_details_report').report_action([], data=data) diff --git a/addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.xml b/addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.xml new file mode 100644 index 00000000000..9716dc12aa1 --- /dev/null +++ b/addons/pos_daily_sales_reports/wizard/pos_daily_sales_reports_wizard.xml @@ -0,0 +1,24 @@ + + + + pos.daily.sales.reports.wizard.form + pos.daily.sales.reports.wizard + +
+
+ + + Daily Reports + pos.daily.sales.reports.wizard + form + new + +