diff --git a/mis_builder_cash_flow_sale/__init__.py b/mis_builder_cash_flow_sale/__init__.py new file mode 100644 index 00000000..067ac12e --- /dev/null +++ b/mis_builder_cash_flow_sale/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import report +from .hooks import create_cashflow_lines diff --git a/mis_builder_cash_flow_sale/__manifest__.py b/mis_builder_cash_flow_sale/__manifest__.py new file mode 100644 index 00000000..0a4965af --- /dev/null +++ b/mis_builder_cash_flow_sale/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2022-2023 Sergio Corato +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "MIS Builder sale cash flow", + "version": "14.0.1.0.0", + "category": "other", + "author": "Sergio Corato", + "summary": "Generate automatically cash flow lines from sale order line.", + "website": "https://github.com/sergiocorato/e-account", + "license": "AGPL-3", + "depends": [ + "mis_builder_cash_flow_inheritable", + "account_payment_sale", + "sale", + "sale_order_line_date", + ], + "data": [ + "views/sale.xml", + "views/cashflow_line.xml", + ], + "installable": True, + "post_init_hook": "create_cashflow_lines", +} diff --git a/mis_builder_cash_flow_sale/hooks.py b/mis_builder_cash_flow_sale/hooks.py new file mode 100644 index 00000000..8224638f --- /dev/null +++ b/mis_builder_cash_flow_sale/hooks.py @@ -0,0 +1,22 @@ +# Copyright 2022 Sergio Corato +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo import SUPERUSER_ID +from odoo.api import Environment + +_logger = logging.getLogger(__name__) + + +def create_cashflow_lines(cr, registry): + with Environment.manage(): + env = Environment(cr, SUPERUSER_ID, {}) + sales = env["sale.order"].search([], order="id") + i_max = len(sales) + i = 0 + for sale in sales: + i += 1 + sale.order_line._refresh_cashflow_line() + _logger.info( + "Creating cashflow line for sale order #%s/%s" % (i, i_max) + ) diff --git a/mis_builder_cash_flow_sale/i18n/it.po b/mis_builder_cash_flow_sale/i18n/it.po new file mode 100644 index 00000000..ab121ae4 --- /dev/null +++ b/mis_builder_cash_flow_sale/i18n/it.po @@ -0,0 +1,96 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mis_builder_cash_flow_purchase +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-01 14:57+0000\n" +"PO-Revision-Date: 2023-05-01 14:57+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mis_builder_cash_flow_purchase +#: model_terms:ir.ui.view,arch_db:mis_builder_cash_flow_purchase.purchase_order_form +msgid "Cash flow" +msgstr "" + +#. module: mis_builder_cash_flow_purchase +#: model_terms:ir.ui.view,arch_db:mis_builder_cash_flow_purchase.purchase_order_form +msgid "Cash flow lines" +msgstr "Righe cash flow" + +#. module: mis_builder_cash_flow_purchase +#: code:addons/mis_builder_cash_flow_purchase/models/purchase.py:119 +#, python-format +msgid "Due line #%s/%s of Purchase order %s" +msgstr "Riga cash flow #%s/%s dell'ordine di acquisto %s" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model.fields,field_description:mis_builder_cash_flow_purchase.field_mis_cash_flow_forecast_line__purchase_balance_forecast +msgid "Forecast balance" +msgstr "Saldo previsto" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model.fields,field_description:mis_builder_cash_flow_purchase.field_purchase_order_line__cashflow_line_ids +msgid "Forecast cashflow line" +msgstr "Riga saldo previsto" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model,name:mis_builder_cash_flow_purchase.model_mis_cash_flow +msgid "MIS Cash Flow" +msgstr "MIS Cash flow" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model,name:mis_builder_cash_flow_purchase.model_mis_cash_flow_forecast_line +msgid "MIS Cash Flow Forecast Line" +msgstr "MIS Cash flow riga previsione" + +#. module: mis_builder_cash_flow_purchase +#: code:addons/mis_builder_cash_flow_purchase/models/purchase.py:35 +#, python-format +msgid "Payment mode %s used in purchase orders must be of type fixed." +msgstr "Il modo di pagamento %s usato negli ordini di acquisto deve essere di tipo fisso." + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model.fields,field_description:mis_builder_cash_flow_purchase.field_mis_cash_flow_forecast_line__purchase_balance_currency +msgid "Purchase Balance Currency" +msgstr "Saldo acquisto in valuta" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model.fields,field_description:mis_builder_cash_flow_purchase.field_mis_cash_flow_forecast_line__purchase_invoiced_percent +msgid "Purchase Invoiced Percent" +msgstr "Fatturazione acquisto (%)" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model,name:mis_builder_cash_flow_purchase.model_purchase_order +msgid "Purchase Order" +msgstr "Ordine di acquisto" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model,name:mis_builder_cash_flow_purchase.model_purchase_order_line +msgid "Purchase Order Line" +msgstr "Riga ordine di acquisto" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model.fields,help:mis_builder_cash_flow_purchase.field_mis_cash_flow_forecast_line__purchase_balance_currency +msgid "Purchase amount in vendor currency recomputed with delivered qty" +msgstr "L'importo dell'acquisto in valuta del fornitore ricalcolato sulla quantità consegnata" + +#. module: mis_builder_cash_flow_purchase +#: model:ir.model.fields,field_description:mis_builder_cash_flow_purchase.field_mis_cash_flow_forecast_line__purchase_line_id +msgid "Purchase order line" +msgstr "Riga ordine di acquisto" + +#. module: mis_builder_cash_flow_purchase +#: model_terms:ir.ui.view,arch_db:mis_builder_cash_flow_purchase.mis_cash_flow_forecast_line_view_form +#: model_terms:ir.ui.view,arch_db:mis_builder_cash_flow_purchase.mis_cash_flow_forecast_line_view_tree +#: model_terms:ir.ui.view,arch_db:mis_builder_cash_flow_purchase.purchase_order_form +msgid "Total forecast balance" +msgstr "Totale saldo previsto" + diff --git a/mis_builder_cash_flow_sale/models/__init__.py b/mis_builder_cash_flow_sale/models/__init__.py new file mode 100644 index 00000000..2e3bd573 --- /dev/null +++ b/mis_builder_cash_flow_sale/models/__init__.py @@ -0,0 +1,2 @@ +from . import cashflow_line +from . import sale diff --git a/mis_builder_cash_flow_sale/models/cashflow_line.py b/mis_builder_cash_flow_sale/models/cashflow_line.py new file mode 100644 index 00000000..c701b054 --- /dev/null +++ b/mis_builder_cash_flow_sale/models/cashflow_line.py @@ -0,0 +1,57 @@ +# Copyright 2022-2023 Sergio Corato +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class CashFlowForecastLine(models.Model): + _inherit = "mis.cash_flow.forecast_line" + + sale_line_id = fields.Many2one( + comodel_name="sale.order.line", + ondelete="cascade", + string="Sale order line", + ) + sale_balance_currency = fields.Monetary( + currency_field="currency_id", + help="Sale amount in vendor currency recomputed with delivered qty", + ) + sale_invoiced_percent = fields.Float( + compute="_compute_balance_forecast", store=True + ) + sale_balance_forecast = fields.Float( + compute="_compute_balance_forecast", + string="Forecast balance", + store=True, + ) + + @api.depends( + "balance", + "sale_balance_currency", + "sale_line_id.qty_invoiced", + "sale_line_id.product_uom_qty", + "sale_line_id.qty_delivered", + "sale_line_id.order_id.commitment_date", + "sale_line_id.order_id.date_order", + "sale_line_id.order_id.currency_id.rate", + ) + def _compute_balance_forecast(self): + for line in self: + if line.sale_line_id: + line.sale_invoiced_percent = line.sale_line_id.qty_invoiced / ( + max( + [ + line.sale_line_id.product_uom_qty, + line.sale_line_id.qty_delivered, + 1, + ] + ) + ) + line.sale_balance_forecast = -line.currency_id._convert( + line.sale_balance_currency or line.balance, + line.sale_line_id.order_id.company_id.currency_id, + line.sale_line_id.order_id.company_id, + line.date, + ) * (1 - line.sale_invoiced_percent) + else: + line.sale_invoiced_percent = 0 + line.sale_balance_forecast = line.balance diff --git a/mis_builder_cash_flow_sale/models/sale.py b/mis_builder_cash_flow_sale/models/sale.py new file mode 100644 index 00000000..7edd0aa6 --- /dev/null +++ b/mis_builder_cash_flow_sale/models/sale.py @@ -0,0 +1,160 @@ +# Copyright 2022-2023 Sergio Corato +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_is_zero, float_round + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def action_confirm(self): + res = super().action_confirm() + self.filtered(lambda x: x.state == "sale").mapped( + "order_line" + )._refresh_cashflow_line() + return res + + def write(self, vals): + res = super().write(vals) + for sale_order in self: + if ( + vals.get("payment_term_id") + or vals.get("commitment_date") + or vals.get("payment_mode_id") + ): + sale_order.order_line._refresh_cashflow_line() + return res + + @api.constrains("payment_mode_id") + def _check_payment_mode(self): + for record in self: + if ( + record.payment_mode_id + and record.payment_mode_id.bank_account_link != "fixed" + ): + raise ValidationError( + _("Payment mode %s used in sale orders must be of type fixed.") + % record.payment_mode_id.name + ) + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + cashflow_line_ids = fields.One2many( + comodel_name="mis.cash_flow.forecast_line", + inverse_name="sale_line_id", + string="Forecast cashflow line", + ) + + @api.model + def create(self, vals): + line = super().create(vals) + line._refresh_cashflow_line() + return line + + def write(self, vals): + res = super().write(vals) + if ( + vals.get("price_unit") + or vals.get("commitment_date") + or vals.get("product_uom_qty") + or vals.get("discount") + or vals.get("discount2") + or vals.get("discount3") + ): + self._refresh_cashflow_line() + return res + + def _refresh_cashflow_line(self): + for line in self: + line.cashflow_line_ids.unlink() + if line.order_id.payment_mode_id.fixed_journal_id: + journal_id = line.order_id.payment_mode_id.fixed_journal_id + if line.price_total < 0: + account_id = journal_id.payment_credit_account_id + else: + account_id = journal_id.payment_debit_account_id + else: + account_ids = self.env["account.account"].search( + [ + ( + "user_type_id", + "=", + self.env.ref("account.data_account_type_liquidity").id, + ), + ("company_id", "=", line.order_id.company_id.id), + ], + limit=1, + ) + if not account_ids: + return False + account_id = account_ids[0] + + # check is there is a residual prevision of amount to pay + # compute actual value of sale_order row + # as price_total do not change if delivered is more than ordered + # (net unit price row * max between ordered and invoiced qty) + max_qty = max([line.product_uom_qty, line.qty_delivered, 1]) + sale_balance_total_currency = ( + float_round( + line.price_total / (line.product_uom_qty or 1), + precision_rounding=line.order_id.currency_id.rounding, + ) + ) * max_qty + # with this value compute not invoiced amount (delivered or not) + # residual balance must be computed on cashflow line as it depends on + # current invoice factor and currency rate + # residual_balance = actual_row_balance * + # (1 - (line.qty_invoiced / max_qty)) + + if not float_is_zero( + sale_balance_total_currency, + precision_rounding=line.order_id.currency_id.rounding, + ): + totlines = [ + ( + ( + line.commitment_date + or line.order_id.commitment_date + or line.order_id.date_order + ).strftime("%Y-%m-%d"), + sale_balance_total_currency, + ) + ] + if line.order_id.payment_term_id: + totlines = line.order_id.payment_term_id.compute( + sale_balance_total_currency, + line.commitment_date + or line.order_id.commitment_date + or line.order_id.date_order, + ) + for i, dueline in enumerate(totlines, start=1): + line.write( + { + "cashflow_line_ids": [ + ( + 0, + 0, + { + "name": _( + "Due line #%s/%s of Sale order %s" + ) + % (i, len(totlines), line.order_id.name), + "date": dueline[0], + "sale_balance_currency": dueline[1], + "currency_id": line.order_id.currency_id.id, + "balance": 0, + "sale_line_id": line.id, + "account_id": account_id.id, + "partner_id": line.order_id.partner_id.id, + "res_id": line.id, + "res_model_id": self.env.ref( + "sale.model_sale_order_line" + ).id, + }, + ) + ] + } + ) diff --git a/mis_builder_cash_flow_sale/report/__init__.py b/mis_builder_cash_flow_sale/report/__init__.py new file mode 100644 index 00000000..00706182 --- /dev/null +++ b/mis_builder_cash_flow_sale/report/__init__.py @@ -0,0 +1 @@ +from . import mis_cash_flow diff --git a/mis_builder_cash_flow_sale/report/mis_cash_flow.py b/mis_builder_cash_flow_sale/report/mis_cash_flow.py new file mode 100644 index 00000000..3ad09f86 --- /dev/null +++ b/mis_builder_cash_flow_sale/report/mis_cash_flow.py @@ -0,0 +1,50 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import models + + +class MisCashFlow(models.Model): + _inherit = "mis.cash_flow" + + def get_cash_flow_query(self): + query = super().get_cash_flow_query() + account_type_receivable = self.env.ref("account.data_account_type_receivable") + sale_query = ( + """ + UNION ALL + SELECT + fl.id as id, + CAST('forecast_line' as varchar) as line_type, + Null as move_line_id, + fl.account_id as account_id, + CASE + WHEN fl.sale_balance_forecast > 0 + THEN fl.sale_balance_forecast * + (1 - fl.sale_invoiced_percent) + ELSE 0.0 + END as debit, + CASE + WHEN fl.sale_balance_forecast < 0 + THEN -fl.sale_balance_forecast * + (1 - fl.sale_invoiced_percent) + ELSE 0.0 + END as credit, + Null as reconciled, + Null as full_reconcile_id, + fl.partner_id as partner_id, + fl.company_id as company_id, + %i as user_type_id, + fl.name as name, + fl.date as date, + CAST('sale_order_line' as varchar) as res_model, + fl.res_id as res_id, + fl.sale_invoiced_percent as invoiced_percent, + fl.currency_id as currency_id, + fl.sale_balance_currency as balance_currency, + fl.sale_balance_forecast as balance_forecast + FROM mis_cash_flow_forecast_line as fl + UNION ALL + """ + % account_type_receivable.id + ) + full_query = query.replace("UNION ALL", sale_query) + return full_query diff --git a/mis_builder_cash_flow_sale/static/description/icon.png b/mis_builder_cash_flow_sale/static/description/icon.png new file mode 100644 index 00000000..ee882205 Binary files /dev/null and b/mis_builder_cash_flow_sale/static/description/icon.png differ diff --git a/mis_builder_cash_flow_sale/tests/__init__.py b/mis_builder_cash_flow_sale/tests/__init__.py new file mode 100644 index 00000000..b6ed1dd5 --- /dev/null +++ b/mis_builder_cash_flow_sale/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mis_builder_cash_flow_purchase diff --git a/mis_builder_cash_flow_sale/tests/test_mis_builder_cash_flow_purchase.py b/mis_builder_cash_flow_sale/tests/test_mis_builder_cash_flow_purchase.py new file mode 100644 index 00000000..c30ccf1c --- /dev/null +++ b/mis_builder_cash_flow_sale/tests/test_mis_builder_cash_flow_purchase.py @@ -0,0 +1,133 @@ +# Copyright 2023 Sergio Corato +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields +from odoo.tests.common import Form, SavepointCase +from odoo.tools.date_utils import relativedelta + + +class TestMisBuilderCashflowPurchase(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.user_model = cls.env["res.users"].with_context(no_reset_password=True) + cls.vendor = cls.env.ref("base.res_partner_3") + cls.product = cls.env.ref("product.product_delivery_01") + cls.product1 = cls.env.ref("product.product_delivery_02") + cls.company = cls.env.ref("base.main_company") + cls.payment_mode_model = cls.env["account.payment.mode"] + cls.journal_model = cls.env["account.journal"] + cls.manual_out = cls.env.ref("account.account_payment_method_manual_out") + cls.manual_out.bank_account_required = True + cls.journal_c1 = cls.journal_model.create( + { + "name": "J1", + "code": "J1", + "type": "bank", + "company_id": cls.company.id, + "bank_acc_number": "123456", + } + ) + cls.payment_term_2rate = cls.env["account.payment.term"].create( + { + "name": "Payment term 30/60 end of month", + "line_ids": [ + ( + 0, + 0, + { + "value": "percent", + "value_amount": 50, + "days": 30, + }, + ), + ( + 0, + 0, + { + "value": "balance", + "days": 30, + "option": "after_invoice_month", + }, + ), + ], + } + ) + cls.account_liquidity = cls.env["account.account"].create( + { + "name": "Bank", + "code": "100999", + "user_type_id": cls.env.ref("account.data_account_type_liquidity").id, + } + ) + cls.supplier_payment_mode = cls.payment_mode_model.create( + { + "name": "Suppliers Bank Payment", + "bank_account_link": "fixed", + "payment_method_id": cls.manual_out.id, + "show_bank_account_from_journal": True, + "company_id": cls.company.id, + "fixed_journal_id": cls.journal_c1.id, + } + ) + + def test_01_purchase_no_payment_term_cashflow(self): + purchase_form = Form(self.env["purchase.order"]) + purchase_form.partner_id = self.vendor + purchase_form.payment_mode_id = self.supplier_payment_mode + with purchase_form.order_line.new() as order_line_form: + order_line_form.product_id = self.product + order_line_form.product_qty = 5.0 + order_line_form.price_unit = 13.0 + order_line_form.date_planned = fields.Date.today() + relativedelta(days=40) + with purchase_form.order_line.new() as order_line_form: + order_line_form.product_id = self.product1 + order_line_form.product_qty = 5.0 + order_line_form.price_unit = 19.0 + order_line_form.date_planned = fields.Date.today() + relativedelta(days=70) + purchase_order = purchase_form.save() + self.assertEqual( + len(purchase_order.order_line), 2, msg="Order line was not created" + ) + purchase_order.button_confirm() + po_lines = purchase_order.order_line.filtered( + lambda x: x.product_id == self.product + ) + self.assertEqual(len(po_lines), 1) + for line in po_lines: + self.assertTrue(line.cashflow_line_ids) + self.assertAlmostEqual( + sum(line.mapped("cashflow_line_ids.purchase_balance_forecast")), + -line.price_total, + ) + + def test_02_purchase_payment_term_2rate_cashflow(self): + purchase_form = Form(self.env["purchase.order"]) + purchase_form.partner_id = self.vendor + purchase_form.payment_term_id = self.payment_term_2rate + purchase_form.payment_mode_id = self.supplier_payment_mode + with purchase_form.order_line.new() as order_line_form: + order_line_form.product_id = self.product + order_line_form.product_qty = 5.0 + order_line_form.price_unit = 13.0 + order_line_form.date_planned = fields.Date.today() + relativedelta(days=40) + with purchase_form.order_line.new() as order_line_form: + order_line_form.product_id = self.product1 + order_line_form.product_qty = 5.0 + order_line_form.price_unit = 19.0 + order_line_form.date_planned = fields.Date.today() + relativedelta(days=70) + purchase_order = purchase_form.save() + self.assertEqual( + len(purchase_order.order_line), 2, msg="Order line was not created" + ) + purchase_order.button_confirm() + po_lines = purchase_order.order_line.filtered( + lambda x: x.product_id == self.product + ) + self.assertEqual(len(po_lines), 1) + for line in po_lines: + self.assertEqual(len(line.cashflow_line_ids), 2) + self.assertAlmostEqual( + sum(line.mapped("cashflow_line_ids.purchase_balance_forecast")), + -line.price_total, + ) diff --git a/mis_builder_cash_flow_sale/views/cashflow_line.xml b/mis_builder_cash_flow_sale/views/cashflow_line.xml new file mode 100644 index 00000000..3d0056dd --- /dev/null +++ b/mis_builder_cash_flow_sale/views/cashflow_line.xml @@ -0,0 +1,37 @@ + + + + mis.cash_flow.forecast_line.form + mis.cash_flow.forecast_line + + + + + + + + + + + + + mis.cash_flow.forecast_line.tree + mis.cash_flow.forecast_line + + + + + + + + + + + + diff --git a/mis_builder_cash_flow_sale/views/sale.xml b/mis_builder_cash_flow_sale/views/sale.xml new file mode 100644 index 00000000..ac036b0c --- /dev/null +++ b/mis_builder_cash_flow_sale/views/sale.xml @@ -0,0 +1,32 @@ + + + + sale.order + + + + + + + + + + + + + + + + + + diff --git a/setup/mis_builder_cash_flow_sale/odoo/addons/mis_builder_cash_flow_sale b/setup/mis_builder_cash_flow_sale/odoo/addons/mis_builder_cash_flow_sale new file mode 120000 index 00000000..41919040 --- /dev/null +++ b/setup/mis_builder_cash_flow_sale/odoo/addons/mis_builder_cash_flow_sale @@ -0,0 +1 @@ +../../../../mis_builder_cash_flow_sale \ No newline at end of file diff --git a/setup/mis_builder_cash_flow_sale/setup.py b/setup/mis_builder_cash_flow_sale/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/mis_builder_cash_flow_sale/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)