From 19fffec6ba95236fe7abe7561480eddfd426c3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Lodeiros?= Date: Wed, 20 Jan 2021 11:41:31 +0100 Subject: [PATCH] 14.0 pms account flow (#36) * [WIP] Basic tests definition * [DEL] Default diff invoicing * [WIP] Reservation refact invoice fields * [FIX] test price without taxes * [FIX] Security csv merge * [WIP]pms: Wizard adv inv views * [ADD] Wizard Filter Invoice Days * [WIP] Payment WorkFlow --- pms/__manifest__.py | 5 + pms/demo/pms_master_data.xml | 8 + pms/models/__init__.py | 4 + pms/models/account_bank_statement.py | 7 + pms/models/account_bank_statement_line.py | 29 + pms/models/account_journal.py | 21 + pms/models/account_move.py | 15 +- pms/models/account_move_line.py | 81 +- pms/models/account_payment.py | 75 +- pms/models/folio_sale_line.py | 857 ++++++++++++++++++ pms/models/pms_board_service_room_type.py | 4 +- pms/models/pms_folio.py | 773 +++++++++++++--- pms/models/pms_property.py | 19 + pms/models/pms_reservation.py | 294 ++++-- pms/models/pms_reservation_line.py | 21 +- pms/models/pms_room.py | 4 +- pms/models/pms_service.py | 20 +- pms/models/product_pricelist.py | 4 + pms/report/pms_folio_templates.xml | 4 +- pms/security/ir.model.access.csv | 5 + pms/tests/test_pms_checkin_partner.py | 1 - pms/tests/test_pms_folio_invoice.py | 43 + pms/tests/test_pms_folio_prices.py | 10 + pms/tests/test_pms_invoice_refund.py | 10 + pms/tests/test_pms_payment.py | 10 + pms/tests/test_pms_pricelist_priority.py | 31 +- pms/tests/test_pms_reservation_prices.py | 12 + pms/tests/test_pms_simple_invoice.py | 10 + pms/views/account_bank_statement_views.xml | 12 + pms/views/account_journal_views.xml | 12 + pms/views/account_move_views.xml | 34 +- pms/views/account_payment_views.xml | 203 ----- pms/views/pms_folio_views.xml | 164 +++- pms/views/pms_reservation_views.xml | 4 + pms/views/pms_room_type_views.xml | 14 +- pms/wizards/__init__.py | 3 + pms/wizards/folio_make_invoice_advance.py | 288 ++++++ .../folio_make_invoice_advance_views.xml | 127 +++ pms/wizards/wizard_invoice_filter_days.py | 114 +++ pms/wizards/wizard_invoice_filter_days.xml | 86 ++ pms/wizards/wizard_payment_folio.py | 124 +++ pms/wizards/wizard_payment_folio.xml | 43 + 42 files changed, 3025 insertions(+), 580 deletions(-) create mode 100644 pms/models/account_bank_statement.py create mode 100644 pms/models/account_bank_statement_line.py create mode 100644 pms/models/account_journal.py create mode 100644 pms/models/folio_sale_line.py create mode 100644 pms/tests/test_pms_folio_invoice.py create mode 100644 pms/tests/test_pms_folio_prices.py create mode 100644 pms/tests/test_pms_invoice_refund.py create mode 100644 pms/tests/test_pms_payment.py create mode 100644 pms/tests/test_pms_reservation_prices.py create mode 100644 pms/tests/test_pms_simple_invoice.py create mode 100644 pms/views/account_bank_statement_views.xml create mode 100644 pms/views/account_journal_views.xml create mode 100644 pms/wizards/folio_make_invoice_advance.py create mode 100644 pms/wizards/folio_make_invoice_advance_views.xml create mode 100644 pms/wizards/wizard_invoice_filter_days.py create mode 100644 pms/wizards/wizard_invoice_filter_days.xml create mode 100644 pms/wizards/wizard_payment_folio.py create mode 100644 pms/wizards/wizard_payment_folio.xml diff --git a/pms/__manifest__.py b/pms/__manifest__.py index 809785251..8ddb84048 100644 --- a/pms/__manifest__.py +++ b/pms/__manifest__.py @@ -35,6 +35,8 @@ # "templates/pms_email_template.xml", "views/general.xml", "data/menus.xml", + "wizards/wizard_payment_folio.xml", + "wizards/folio_make_invoice_advance_views.xml", "views/pms_amenity_views.xml", "views/pms_amenity_type_views.xml", "views/pms_board_service_views.xml", @@ -50,6 +52,7 @@ "views/pms_room_closure_reason_views.xml", "views/account_payment_views.xml", "views/account_move_views.xml", + "views/account_bank_statement_views.xml", "views/res_users_views.xml", "views/pms_room_type_class_views.xml", "views/pms_room_type_availability_plan_views.xml", @@ -64,10 +67,12 @@ "views/product_template_views.xml", "views/webclient_templates.xml", "views/ir_sequence_views.xml", + "views/account_journal_views.xml", "wizards/wizard_reservation.xml", "wizards/wizard_massive_changes.xml", "wizards/wizard_advanced_filters.xml", "wizards/wizard_folio.xml", + "wizards/wizard_invoice_filter_days.xml", ], "demo": [ "demo/pms_master_data.xml", diff --git a/pms/demo/pms_master_data.xml b/pms/demo/pms_master_data.xml index 0d118bf2f..9ec81ce2d 100644 --- a/pms/demo/pms_master_data.xml +++ b/pms/demo/pms_master_data.xml @@ -119,24 +119,28 @@ 2 + Single-101 1 + Single-102 1 + Single-103 1 + Double-201 @@ -144,24 +148,28 @@ 2 1 + Double-202 2 + Triple-203 3 + Open Talk Away Room 1 + diff --git a/pms/models/__init__.py b/pms/models/__init__.py index 7904ab766..cad3603fa 100644 --- a/pms/models/__init__.py +++ b/pms/models/__init__.py @@ -41,3 +41,7 @@ from . import pms_board_service_room_type_line from . import pms_board_service_line from . import account_move_line from . import pms_cancelation_rule +from . import folio_sale_line +from . import account_bank_statement_line +from . import account_bank_statement +from . import account_journal diff --git a/pms/models/account_bank_statement.py b/pms/models/account_bank_statement.py new file mode 100644 index 000000000..5e14cfdfb --- /dev/null +++ b/pms/models/account_bank_statement.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class AccountBankStatement(models.Model): + _inherit = "account.bank.statement" + + property_id = fields.Many2one("pms.property", string="Property", copy=False) diff --git a/pms/models/account_bank_statement_line.py b/pms/models/account_bank_statement_line.py new file mode 100644 index 000000000..57a8e69af --- /dev/null +++ b/pms/models/account_bank_statement_line.py @@ -0,0 +1,29 @@ +from odoo import api, fields, models + + +class AccountBankStatementLine(models.Model): + _inherit = "account.bank.statement.line" + + statement_folio_ids = fields.Many2many( + "pms.folio", string="Folios", ondelete="cascade" + ) + reservation_ids = fields.Many2many( + "pms.reservation", string="Reservations", ondelete="cascade" + ) + service_ids = fields.Many2many("pms.service", string="Services", ondelete="cascade") + + @api.model + def _prepare_move_line_default_vals(self, counterpart_account_id=None): + line_vals_list = super( + AccountBankStatementLine, self + )._prepare_move_line_default_vals(counterpart_account_id) + if self.statement_folio_ids: + for line in line_vals_list: + line.update( + { + "folio_ids": [(6, 0, self.statement_folio_ids.ids)], + "reservation_ids": [(6, 0, self.reservation_ids.ids)], + "service_ids": [(6, 0, self.service_ids.ids)], + } + ) + return line_vals_list diff --git a/pms/models/account_journal.py b/pms/models/account_journal.py new file mode 100644 index 000000000..d13ab724d --- /dev/null +++ b/pms/models/account_journal.py @@ -0,0 +1,21 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + pms_property_ids = fields.Many2many("pms.property", string="Property", copy=False) + + @api.constrains("pms_property_ids", "company_id") + def _check_property_company_integrity(self): + for rec in self: + if rec.company_id and rec.pms_property_ids: + property_companies = rec.pms_property_ids.mapped("company_id") + if len(property_companies) > 1 or rec.company_id != property_companies: + raise UserError( + _( + "The company of the properties must match " + "the company on account journal" + ) + ) diff --git a/pms/models/account_move.py b/pms/models/account_move.py index f8f01adbb..7b36ac26c 100644 --- a/pms/models/account_move.py +++ b/pms/models/account_move.py @@ -1,4 +1,3 @@ -# Copyright 2017 Alexandre Díaz # Copyright 2017 Dario Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import json @@ -15,7 +14,7 @@ class AccountMove(models.Model): comodel_name="pms.folio", compute="_compute_folio_origin" ) pms_property_id = fields.Many2one("pms.property") - from_folio = fields.Boolean(compute="_compute_folio_origin") + from_reservation = fields.Boolean(compute="_compute_from_reservation") outstanding_folios_debits_widget = fields.Text( compute="_compute_get_outstanding_folios_JSON" ) @@ -27,14 +26,17 @@ class AccountMove(models.Model): def _compute_folio_origin(self): for inv in self: - inv.from_folio = False inv.folio_ids = False - folios = inv.mapped("invoice_line_ids.reservation_ids.folio_id") - folios |= inv.mapped("invoice_line_ids.service_ids.folio_id") + folios = inv.mapped("invoice_line_ids.folio_ids") if folios: - inv.from_folio = True inv.folio_ids = [(6, 0, folios.ids)] + def _compute_from_reservation(self): + for inv in self: + inv.from_reservation = False + if len(inv.invoice_line_ids.mapped("reservation_line_ids")) > 0: + inv.from_reservation = True + # Action methods def action_folio_payments(self): @@ -55,6 +57,7 @@ class AccountMove(models.Model): } # Business methods + def _compute_get_outstanding_folios_JSON(self): self.ensure_one() self.outstanding_folios_debits_widget = json.dumps(False) diff --git a/pms/models/account_move_line.py b/pms/models/account_move_line.py index 82f22403a..032b0edf4 100644 --- a/pms/models/account_move_line.py +++ b/pms/models/account_move_line.py @@ -1,7 +1,7 @@ # Copyright 2017 Alexandre Díaz # Copyright 2017 Dario Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import fields, models +from odoo import api, fields, models class AccountMoveLine(models.Model): @@ -35,3 +35,82 @@ class AccountMoveLine(models.Model): readonly=True, copy=False, ) + folio_line_ids = fields.Many2many( + "folio.sale.line", + "folio_sale_line_invoice_rel", + "invoice_line_id", + "sale_line_id", + string="Folio Lines", + copy=False, + ) + folio_ids = fields.Many2many( + "pms.folio", + "payment_folio_rel", + "move_id", + "folio_id", + string="Folios", + ondelete="cascade", + compute="_compute_folio_ids", + readonly=False, + store=True, + ) + name = fields.Char( + compute="_compute_name", + readonly=False, + store=True, + ) + + @api.depends("service_ids", "reservation_ids") + def _compute_folio_ids(self): + for record in self: + if record.service_ids: + record.folio_ids = record.mapped("service_ids.folio_id") + elif record.reservation_ids: + record.folio_ids = record.mapped("reservation_ids.folio_id") + elif not record.folio_ids: + record.folio_ids = False + + def invoice_filter_days(self): + action = self.env["ir.actions.act_window"]._for_xml_id( + "pms.pms_invoice_filter_days_action" + ) + # Force the values of the move line in the context to avoid issues + ctx = dict(self.env.context) + ctx.pop("active_id", None) + ctx["active_ids"] = self.ids + ctx["active_model"] = "account.move.line" + action["context"] = ctx + return action + + def _copy_data_extend_business_fields(self, values): + super(AccountMoveLine, self)._copy_data_extend_business_fields(values) + values["folio_line_ids"] = [(6, None, self.folio_line_ids.ids)] + values["reservation_line_ids"] = [(6, None, self.reservation_line_ids.ids)] + values["service_ids"] = [(6, None, self.service_ids.ids)] + values["reservation_ids"] = [(6, None, self.reservation_ids.ids)] + + @api.depends("reservation_line_ids") + def _compute_name(self): + if hasattr(super(), "_compute_name"): + super()._compute_field() + for record in self: + if record.reservation_line_ids: + record.name = record._get_compute_name() + + def _get_compute_name(self): + self.ensure_one() + if self.reservation_line_ids: + month = False + name = False + lines = self.reservation_line_ids.sorted("date") + for date in lines.mapped("date"): + if date.month != month: + name = name + "\n" if name else "" + name += date.strftime("%B-%Y") + ": " + name += date.strftime("%d") + month = date.month + else: + name += ", " + date.strftime("%d") + return name + else: + return False diff --git a/pms/models/account_payment.py b/pms/models/account_payment.py index 76742d9f3..389e1b9e3 100644 --- a/pms/models/account_payment.py +++ b/pms/models/account_payment.py @@ -1,7 +1,6 @@ # Copyright 2017 Dario Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models -from odoo.exceptions import except_orm +from odoo import _, fields, models class AccountPayment(models.Model): @@ -9,78 +8,6 @@ class AccountPayment(models.Model): # Fields declaration folio_id = fields.Many2one("pms.folio", string="Folio Reference") - amount_total_folio = fields.Float( - compute="_compute_folio_amount", - store=True, - string="Total amount in folio", - ) - save_amount = fields.Monetary(string="onchange_amount") - save_date = fields.Date() - save_journal_id = fields.Integer() - - # Compute and Search methods - - @api.depends("state") - def _compute_folio_amount(self): - # FIXME: Finalize method - res = [] - fol = () - for payment in self: - if payment.folio_id: - fol = payment.env["pms.folio"].search( - [("id", "=", payment.folio_id.id)] - ) - else: - return - if not any(fol): - return - if len(fol) > 1: - raise except_orm( - _("Warning"), - _( - "This pay is related with \ - more than one Reservation." - ), - ) - else: - fol.compute_amount() - return res - - # Constraints and onchanges - # @api.onchange("amount", "payment_date", "journal_id") - # def onchange_amount(self): - # if self._origin: - # self.save_amount = self._origin.amount - # self.save_journal_id = self._origin.journal_id.id - # self.save_date = self._origin.payment_date - - # Action methods - # def return_payment_folio(self): - # journal = self.journal_id - # partner = self.partner_id - # amount = self.amount - # reference = self.communication - # account_move_lines = self.move_line_ids.filtered( - # lambda x: (x.account_id.internal_type == "receivable") - # ) - # return_line_vals = { - # "move_line_ids": [(6, False, [x.id for x in account_move_lines])], - # "partner_id": partner.id, - # "amount": amount, - # "reference": reference, - # } - # return_vals = { - # "journal_id": journal.id, - # "line_ids": [(0, 0, return_line_vals)], - # } - # return_pay = self.env["payment.return"].create(return_vals) - # if self.save_amount: - # self.amount = self.save_amount - # if self.save_date: - # self.payment_date = self.save_date - # if self.save_journal_id: - # self.journal_id = self.env["account.journal"].browse(self.save_journal_id) - # return_pay.action_confirm() # Business methods diff --git a/pms/models/folio_sale_line.py b/pms/models/folio_sale_line.py new file mode 100644 index 000000000..fde1810a8 --- /dev/null +++ b/pms/models/folio_sale_line.py @@ -0,0 +1,857 @@ +# Copyright 2020 Dario Lodeiros +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.osv import expression +from odoo.tools import float_compare, float_is_zero + + +class FolioSaleLine(models.Model): + _name = "folio.sale.line" + _description = "Folio Sale Line" + _order = "folio_id, sequence, id" + _check_company_auto = True + + @api.depends("state", "product_uom_qty", "qty_to_invoice", "qty_invoiced") + def _compute_invoice_status(self): + """ + Compute the invoice status of a SO line: + Its if compute based on reservations/services associated status + """ + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + for line in self: + if line.state == "draft": + line.invoice_status = "no" + elif not float_is_zero(line.qty_to_invoice, precision_digits=precision): + line.invoice_status = "to invoice" + elif ( + float_compare( + line.qty_invoiced, + line.product_uom_qty, + precision_digits=precision, + ) + >= 0 + ): + line.invoice_status = "invoiced" + else: + line.invoice_status = "no" + + @api.depends("reservation_line_ids", "service_id") + def _compute_name(self): + for record in self: + if not record.name_updated: + record.name = record._get_compute_name() + + @api.depends("name") + def _compute_name_updated(self): + self.name_updated = False + for record in self.filtered("name"): + if record.name != record._get_compute_name(): + record.name_updated = True + + def _get_compute_name(self): + self.ensure_one() + if self.reservation_line_ids: + month = False + name = False + lines = self.reservation_line_ids.sorted("date") + for date in lines.mapped("date"): + if date.month != month: + name = name + "\n" if name else "" + name += date.strftime("%B-%Y") + ": " + name += date.strftime("%d") + month = date.month + else: + name += ", " + date.strftime("%d") + return name + elif self.service_id: + return self.service_id.name + else: + return False + + @api.depends("service_id", "service_id.price_unit") + def _compute_price_unit(self): + """ + Compute unit prices of services + On reservations the unit price is compute by group in folio + """ + for record in self: + if record.service_id: + record.price_unit = record.service_id.price_unit + elif not record.price_unit: + record.price_unit = False + + @api.depends("product_uom_qty", "discount", "price_unit", "tax_ids") + def _compute_amount(self): + """ + Compute the amounts of the Sale line. + """ + for line in self: + price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = line.tax_ids.compute_all( + price, + line.folio_id.currency_id, + line.product_uom_qty, + product=line.product_id, + ) + line.update( + { + "price_tax": sum( + t.get("amount", 0.0) for t in taxes.get("taxes", []) + ), + "price_total": taxes["total_included"], + "price_subtotal": taxes["total_excluded"], + } + ) + if self.env.context.get( + "import_file", False + ) and not self.env.user.user_has_groups("account.group_account_manager"): + line.tax_ids.invalidate_cache( + ["invoice_repartition_line_ids"], [line.tax_ids.id] + ) + + @api.depends("reservation_id.tax_ids", "service_id.tax_ids") + def _compute_tax_ids(self): + for record in self: + record.tax_ids = ( + record.service_id.tax_ids + if record.service_id + else record.reservation_id.tax_ids + ) + + @api.depends("service_id", "service_id.discount") + def _compute_discount(self): + self.discount = 0.0 + for record in self.filtered("service_id"): + record.discount = record.service_id.discount + + @api.depends("reservation_id.room_type_id", "service_id.product_id") + def _compute_product_id(self): + for record in self: + if record.reservation_id: + record.product_id = record.reservation_id.room_type_id.product_id + elif record.service_id: + record.product_id = record.service_id.product_id + else: + record.product_id = False + + # @api.depends('product_id', 'folio_id.state', 'qty_invoiced', 'qty_delivered') + # def _compute_product_updatable(self): + # for line in self: + # if line.state in ['done', 'cancel'] or ( + # line.state == 'sale' and ( + # line.qty_invoiced > 0 or line.qty_delivered > 0)): + # line.product_updatable = False + # else: + # line.product_updatable = True + + # no trigger product_id.invoice_policy to avoid retroactively changing SO + @api.depends("qty_invoiced", "product_uom_qty", "folio_id.state") + def _compute_get_to_invoice_qty(self): + """ + Compute the quantity to invoice. + If the invoice policy is order, the quantity to invoice is + calculated from the ordered quantity. + Otherwise, the quantity delivered is used. + """ + for line in self: + if line.folio_id.state not in ["draft"]: + line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced + else: + line.qty_to_invoice = 0 + + @api.depends( + "invoice_lines.move_id.state", + "invoice_lines.quantity", + "untaxed_amount_to_invoice", + ) + def _compute_get_invoice_qty(self): + """ + Compute the quantity invoiced. If case of a refund, + the quantity invoiced is decreased. Note + that this is the case only if the refund is + generated from the Folio and that is intentional: if + a refund made would automatically decrease the invoiced quantity, + then there is a risk of reinvoicing + it automatically, which may not be wanted at all. + That's why the refund has to be created from the Folio + """ + for line in self: + qty_invoiced = 0.0 + for invoice_line in line.invoice_lines: + if invoice_line.move_id.state != "cancel": + if invoice_line.move_id.move_type == "out_invoice": + qty_invoiced += invoice_line.product_uom_id._compute_quantity( + invoice_line.quantity, line.product_uom + ) + elif invoice_line.move_id.move_type == "out_refund": + if ( + not line.is_downpayment + or line.untaxed_amount_to_invoice == 0 + ): + qty_invoiced -= ( + invoice_line.product_uom_id._compute_quantity( + invoice_line.quantity, line.product_uom + ) + ) + line.qty_invoiced = qty_invoiced + + @api.depends("price_unit", "discount") + def _compute_get_price_reduce(self): + for line in self: + line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0) + + @api.depends("price_total", "product_uom_qty") + def _compute_get_price_reduce_tax(self): + for line in self: + line.price_reduce_taxinc = ( + line.price_total / line.product_uom_qty if line.product_uom_qty else 0.0 + ) + + @api.depends("price_subtotal", "product_uom_qty") + def _compute_get_price_reduce_notax(self): + for line in self: + line.price_reduce_taxexcl = ( + line.price_subtotal / line.product_uom_qty + if line.product_uom_qty + else 0.0 + ) + + # @api.model + # def _prepare_add_missing_fields(self, values): + # """ Deduce missing required fields from the onchange """ + # res = {} + # onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_ids'] + # if values.get('folio_id') and values.get('product_id') and any( + # f not in values for f in onchange_fields + # ): + # line = self.new(values) + # line.product_id_change() + # for field in onchange_fields: + # if field not in values: + # res[field] = line._fields[field].convert_to_write( + # line[field], line + # ) + # return res + + # @api.model_create_multi + # def create(self, vals_list): + # for values in vals_list: + # if values.get('display_type', self.default_get( + # ['display_type'])['display_type'] + # ): + # values.update(product_id=False, price_unit=0, + # product_uom_qty=0, product_uom=False, + # customer_lead=0) + + # values.update(self._prepare_add_missing_fields(values)) + + # lines = super().create(vals_list) + # for line in lines: + # if line.product_id and line.folio_id.state == 'sale': + # msg = _("Extra line with %s ") % (line.product_id.display_name,) + # line.folio_id.message_post(body=msg) + # # create an analytic account if at least an expense product + # if line.product_id.expense_policy not in [False, 'no'] and \ + # not line.folio_id.analytic_account_id: + # line.folio_id._create_analytic_account() + # return lines + + # _sql_constraints = [ + # ('accountable_required_fields', + # "CHECK(display_type IS NOT NULL OR \ + # (product_id IS NOT NULL AND product_uom IS NOT NULL))", + # "Missing required fields on accountable sale order line."), + # ('non_accountable_null_fields', + # "CHECK(display_type IS NULL OR (product_id IS NULL AND \ + # price_unit = 0 AND product_uom_qty = 0 AND \ + # product_uom IS NULL AND customer_lead = 0))", + # "Forbidden values on non-accountable sale order line"), + # ] + + def _update_line_quantity(self, values): + folios = self.mapped("folio_id") + for order in folios: + order_lines = self.filtered(lambda x: x.folio_id == order) + msg = "" + _("The ordered quantity has been updated.") + "
    " + for line in order_lines: + msg += "
  • %s:
    " % line.product_id.display_name + msg += ( + _( + "Ordered Quantity: %(old_qty)s -> %(new_qty)s", + old_qty=line.product_uom_qty, + new_qty=values["product_uom_qty"], + ) + + "
    " + ) + # if line.product_id.type in ('consu', 'product'): + # msg += _("Delivered Quantity: %s", line.qty_delivered) + "
    " + msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "
    " + msg += "
" + order.message_post(body=msg) + + # def write(self, values): + # if 'display_type' in values and self.filtered( + # lambda line: line.display_type != values.get('display_type')): + # raise UserError(_("You cannot change the type of a sale order line.\ + # Instead you should delete the current line and create \ + # a new line of the proper type.")) + + # if 'product_uom_qty' in values: + # precision = self.env['decimal.precision'].precision_get( + # 'Product Unit of Measure' + # ) + # self.filtered( + # lambda r: r.state == 'sale' and \ + # float_compare( + # r.product_uom_qty, + # values['product_uom_qty'], + # precision_digits=precision) != 0)._update_line_quantity( + # values + # ) + + # # Prevent writing on a locked SO. + # protected_fields = self._get_protected_fields() + # if 'done' in self.mapped('folio_id.state') and any( + # f in values.keys() for f in protected_fields + # ): + # protected_fields_modified = list(set(protected_fields) & set( + # values.keys() + # )) + # fields = self.env['ir.model.fields'].search([ + # ('name', 'in', protected_fields_modified), + # ('model', '=', self._name) + # ]) + # raise UserError( + # _('It is forbidden to modify the following \ + # fields in a locked order:\n%s') + # % '\n'.join(fields.mapped('field_description')) + # ) + + # result = super(SaleOrderLine, self).write(values) + # return result + + folio_id = fields.Many2one( + "pms.folio", + string="Folio Reference", + required=True, + ondelete="cascade", + index=True, + copy=False, + ) + reservation_id = fields.Many2one( + "pms.reservation", + string="Reservation Reference", + ondelete="cascade", + index=True, + copy=False, + ) + service_id = fields.Many2one( + "pms.service", + string="Service Reference", + ondelete="cascade", + index=True, + copy=False, + ) + name = fields.Text( + string="Description", compute="_compute_name", store=True, readonly=False + ) + name_updated = fields.Boolean(compute="_compute_name_updated", store=True) + reservation_line_ids = fields.Many2many( + "pms.reservation.line", + string="Nights", + ) + sequence = fields.Integer(string="Sequence", default=10) + + invoice_lines = fields.Many2many( + "account.move.line", + "folio_sale_line_invoice_rel", + "sale_line_id", + "invoice_line_id", + string="Invoice Lines", + copy=False, + ) + invoice_status = fields.Selection( + [ + ("upselling", "Upselling Opportunity"), + ("invoiced", "Fully Invoiced"), + ("to invoice", "To Invoice"), + ("no", "Nothing to Invoice"), + ], + string="Invoice Status", + compute="_compute_invoice_status", + store=True, + readonly=True, + default="no", + ) + price_unit = fields.Float( + "Unit Price", + digits="Product Price", + compute="_compute_price_unit", + store=True, + ) + + price_subtotal = fields.Monetary( + compute="_compute_amount", string="Subtotal", readonly=True, store=True + ) + price_tax = fields.Float( + compute="_compute_amount", string="Total Tax", readonly=True, store=True + ) + price_total = fields.Monetary( + compute="_compute_amount", string="Total", readonly=True, store=True + ) + price_reduce = fields.Float( + compute="_compute_get_price_reduce", + string="Price Reduce", + digits="Product Price", + readonly=True, + store=True, + ) + tax_ids = fields.Many2many( + "account.tax", + compute="_compute_tax_ids", + store=True, + string="Taxes", + domain=["|", ("active", "=", False), ("active", "=", True)], + ) + price_reduce_taxinc = fields.Monetary( + compute="_compute_get_price_reduce_tax", + string="Price Reduce Tax inc", + readonly=True, + store=True, + ) + price_reduce_taxexcl = fields.Monetary( + compute="_compute_get_price_reduce_notax", + string="Price Reduce Tax excl", + readonly=True, + store=True, + ) + + discount = fields.Float( + string="Discount (%)", + digits="Discount", + compute="_compute_discount", + store=True, + ) + + product_id = fields.Many2one( + "product.product", + string="Product", + domain="[('sale_ok', '=', True),\ + '|', ('company_id', '=', False), \ + ('company_id', '=', company_id)]", + change_default=True, + ondelete="restrict", + check_company=True, + compute="_compute_product_id", + store=True, + ) + # product_updatable = fields.Boolean( + # compute='_compute_product_updatable', + # string='Can Edit Product', + # readonly=True, + # default=True) + product_uom_qty = fields.Float( + string="Quantity", + digits="Product Unit of Measure", + compute="_compute_product_uom_qty", + store=True, + readonly=False, + ) + product_uom = fields.Many2one( + "uom.uom", + string="Unit of Measure", + domain="[('category_id', '=', product_uom_category_id)]", + ) + product_uom_category_id = fields.Many2one( + related="product_id.uom_id.category_id", readonly=True + ) + product_uom_readonly = fields.Boolean(compute="_compute_product_uom_readonly") + product_custom_attribute_value_ids = fields.One2many( + "product.attribute.custom.value", + "sale_order_line_id", + string="Custom Values", + copy=True, + ) + + qty_to_invoice = fields.Float( + compute="_compute_get_to_invoice_qty", + string="To Invoice Quantity", + store=True, + readonly=True, + digits="Product Unit of Measure", + ) + qty_invoiced = fields.Float( + compute="_compute_get_invoice_qty", + string="Invoiced Quantity", + store=True, + readonly=True, + compute_sudo=True, + digits="Product Unit of Measure", + ) + + untaxed_amount_invoiced = fields.Monetary( + "Untaxed Invoiced Amount", + compute="_compute_untaxed_amount_invoiced", + compute_sudo=True, + store=True, + ) + untaxed_amount_to_invoice = fields.Monetary( + "Untaxed Amount To Invoice", + compute="_compute_untaxed_amount_to_invoice", + compute_sudo=True, + store=True, + ) + + currency_id = fields.Many2one( + related="folio_id.currency_id", + depends=["folio_id.currency_id"], + store=True, + string="Currency", + readonly=True, + ) + company_id = fields.Many2one( + related="folio_id.company_id", + string="Company", + store=True, + readonly=True, + index=True, + ) + folio_partner_id = fields.Many2one( + related="folio_id.partner_id", store=True, string="Customer", readonly=False + ) + analytic_tag_ids = fields.Many2many( + "account.analytic.tag", + string="Analytic Tags", + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + ) + analytic_line_ids = fields.One2many( + "account.analytic.line", "so_line", string="Analytic lines" + ) + is_downpayment = fields.Boolean( + string="Is a down payment", + help="Down payments are made when creating invoices from a folio." + " They are not copied when duplicating a folio.", + ) + + state = fields.Selection( + related="folio_id.state", + string="Folio Status", + readonly=True, + copy=False, + store=True, + ) + + display_type = fields.Selection( + [("line_section", "Section"), ("line_note", "Note")], + default=False, + help="Technical field for UX purpose.", + ) + + @api.depends("reservation_line_ids", "service_id") + def _compute_product_uom_qty(self): + for line in self: + if line.reservation_line_ids: + line.product_uom_qty = len(line.reservation_line_ids) + elif line.service_id: + line.product_uom_qty = line.service_id.product_qty + elif not line.product_uom_qty: + line.product_uom_qty = False + + @api.depends("state") + def _compute_product_uom_readonly(self): + for line in self: + line.product_uom_readonly = line.state in ["sale", "done", "cancel"] + + @api.depends( + "invoice_lines", + "invoice_lines.price_total", + "invoice_lines.move_id.state", + "invoice_lines.move_id.move_type", + ) + def _compute_untaxed_amount_invoiced(self): + """Compute the untaxed amount already invoiced from + the sale order line, taking the refund attached + the so line into account. This amount is computed as + SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) + where + `inv_line` is a customer invoice line linked to the SO line + `ref_line` is a customer credit note (refund) line linked to the SO line + """ + for line in self: + amount_invoiced = 0.0 + for invoice_line in line.invoice_lines: + if invoice_line.move_id.state == "posted": + invoice_date = ( + invoice_line.move_id.invoice_date or fields.Date.today() + ) + if invoice_line.move_id.move_type == "out_invoice": + amount_invoiced += invoice_line.currency_id._convert( + invoice_line.price_subtotal, + line.currency_id, + line.company_id, + invoice_date, + ) + elif invoice_line.move_id.move_type == "out_refund": + amount_invoiced -= invoice_line.currency_id._convert( + invoice_line.price_subtotal, + line.currency_id, + line.company_id, + invoice_date, + ) + line.untaxed_amount_invoiced = amount_invoiced + + @api.depends( + "state", + "price_reduce", + "product_id", + "untaxed_amount_invoiced", + "product_uom_qty", + ) + def _compute_untaxed_amount_to_invoice(self): + """Total of remaining amount to invoice on the sale order line (taxes excl.) as + total_sol - amount already invoiced + where Total_sol depends on the invoice policy of the product. + + Note: Draft invoice are ignored on purpose, the 'to invoice' amount should + come only from the SO lines. + """ + for line in self: + amount_to_invoice = 0.0 + if line.state != "draft": + # Note: do not use price_subtotal field as it returns + # zero when the ordered quantity is zero. + # It causes problem for expense line (e.i.: ordered qty = 0, + # deli qty = 4, price_unit = 20 ; subtotal is zero), + # but when you can invoice the line, + # you see an amount and not zero. + # Since we compute untaxed amount, we can use directly the price + # reduce (to include discount) without using `compute_all()` + # method on taxes. + price_subtotal = 0.0 + price_subtotal = line.price_reduce * line.product_uom_qty + if len(line.tax_ids.filtered(lambda tax: tax.price_include)) > 0: + # As included taxes are not excluded from the computed subtotal, + # `compute_all()` method has to be called to retrieve + # the subtotal without them. + # `price_reduce_taxexcl` cannot be used as it is computed from + # `price_subtotal` field. (see upper Note) + price_subtotal = line.tax_ids.compute_all( + price_subtotal, + currency=line.folio_id.currency_id, + quantity=line.product_uom_qty, + product=line.product_id, + partner=line.folio_id.partner_shipping_id, + )["total_excluded"] + + if any( + line.invoice_lines.mapped(lambda l: l.discount != line.discount) + ): + # In case of re-invoicing with different + # discount we try to calculate manually the + # remaining amount to invoice + amount = 0 + for inv_line in line.invoice_lines: + if ( + len( + inv_line.tax_ids.filtered(lambda tax: tax.price_include) + ) + > 0 + ): + amount += inv_line.tax_ids.compute_all( + inv_line.currency_id._convert( + inv_line.price_unit, + line.currency_id, + line.company_id, + inv_line.date or fields.Date.today(), + round=False, + ) + * inv_line.quantity + )["total_excluded"] + else: + amount += ( + inv_line.currency_id._convert( + inv_line.price_unit, + line.currency_id, + line.company_id, + inv_line.date or fields.Date.today(), + round=False, + ) + * inv_line.quantity + ) + + amount_to_invoice = max(price_subtotal - amount, 0) + else: + amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced + + line.untaxed_amount_to_invoice = amount_to_invoice + + def _get_invoice_line_sequence(self, new=0, old=0): + """ + Method intended to be overridden in third-party + module if we want to prevent the resequencing of invoice lines. + + :param int new: the new line sequence + :param int old: the old line sequence + + :return: the sequence of the SO line, by default the new one. + """ + return new or old + + def _prepare_invoice_line(self, **optional_values): + """ + Prepare the dict of values to create the new invoice line for a folio sale line. + + :param qty: float quantity to invoice + :param optional_values: any parameter that + should be added to the returned invoice line + """ + self.ensure_one() + reservation = self.reservation_id + service = self.service_id + reservation_lines = self.reservation_line_ids.filtered(lambda l: not l.invoiced) + res = { + "display_type": self.display_type, + "sequence": self.sequence, + "name": self.name, + "product_id": self.product_id.id, + "product_uom_id": self.product_uom.id, + "quantity": self.qty_to_invoice, + "discount": self.discount, + "price_unit": self.price_unit, + "tax_ids": [(6, 0, self.tax_ids.ids)], + "analytic_account_id": self.folio_id.analytic_account_id.id, + "analytic_tag_ids": [(6, 0, self.analytic_tag_ids.ids)], + "folio_line_ids": [(6, 0, [self.id])], + "reservation_ids": [(6, 0, reservation.ids)], + "service_ids": [(6, 0, service.ids)], + "reservation_line_ids": [(6, 0, reservation_lines.ids)], + } + if optional_values: + res.update(optional_values) + if self.display_type: + res["account_id"] = False + return res + + def name_get(self): + result = [] + for so_line in self.sudo(): + name = "{} - {}".format( + so_line.folio_id.name, + so_line.name and so_line.name.split("\n")[0] or so_line.product_id.name, + ) + result.append((so_line.id, name)) + return result + + @api.model + def _name_search( + self, name, args=None, operator="ilike", limit=100, name_get_uid=None + ): + if operator in ("ilike", "like", "=", "=like", "=ilike"): + args = expression.AND( + [ + args or [], + ["|", ("folio_id.name", operator, name), ("name", operator, name)], + ] + ) + return super(FolioSaleLine, self)._name_search( + name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid + ) + + def _check_line_unlink(self): + """ + Check wether a line can be deleted or not. + + Lines cannot be deleted if the folio is confirmed; downpayment + lines who have not yet been invoiced bypass that exception. + :rtype: recordset folio.sale.line + :returns: set of lines that cannot be deleted + """ + return self.filtered( + lambda line: line.state not in ("draft") + and (line.invoice_lines or not line.is_downpayment) + ) + + # def unlink(self): + # if self._check_line_unlink(): + # raise UserError( + # _("""You can not remove an sale line once the sales + # folio is confirmed.\n + # You should rather set the quantity to 0.""") + # ) + # return super(FolioSaleLine, self).unlink() + + def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id): + """Retrieve the price before applying the pricelist + :param obj product: object of current product record + :parem float qty: total quentity of product + :param tuple price_and_rule: tuple(price, suitable_rule) + coming from pricelist computation + :param obj uom: unit of measure of current folio line + :param integer pricelist_id: pricelist id of folio""" + PricelistItem = self.env["product.pricelist.item"] + field_name = "lst_price" + currency_id = None + product_currency = product.currency_id + if rule_id: + pricelist_item = PricelistItem.browse(rule_id) + if pricelist_item.pricelist_id.discount_policy == "without_discount": + while ( + pricelist_item.base == "pricelist" + and pricelist_item.base_pricelist_id + and pricelist_item.base_pricelist_id.discount_policy + == "without_discount" + ): + price, rule_id = pricelist_item.base_pricelist_id.with_context( + uom=uom.id + ).get_product_price_rule(product, qty, self.folio_id.partner_id) + pricelist_item = PricelistItem.browse(rule_id) + + if pricelist_item.base == "standard_price": + field_name = "standard_price" + product_currency = product.cost_currency_id + elif ( + pricelist_item.base == "pricelist" and pricelist_item.base_pricelist_id + ): + field_name = "price" + product = product.with_context( + pricelist=pricelist_item.base_pricelist_id.id + ) + product_currency = pricelist_item.base_pricelist_id.currency_id + currency_id = pricelist_item.pricelist_id.currency_id + + if not currency_id: + currency_id = product_currency + cur_factor = 1.0 + else: + if currency_id.id == product_currency.id: + cur_factor = 1.0 + else: + cur_factor = currency_id._get_conversion_rate( + product_currency, + currency_id, + self.company_id or self.env.company, + self.folio_id.date_order or fields.Date.today(), + ) + + product_uom = self.env.context.get("uom") or product.uom_id.id + if uom and uom.id != product_uom: + # the unit price is in a different uom + uom_factor = uom._compute_price(1.0, product.uom_id) + else: + uom_factor = 1.0 + + return product[field_name] * uom_factor * cur_factor, currency_id + + def _get_protected_fields(self): + return [ + "product_id", + "name", + "price_unit", + "product_uom", + "product_uom_qty", + "tax_ids", + "analytic_tag_ids", + ] diff --git a/pms/models/pms_board_service_room_type.py b/pms/models/pms_board_service_room_type.py index fae85fa23..1b63ae6ab 100644 --- a/pms/models/pms_board_service_room_type.py +++ b/pms/models/pms_board_service_room_type.py @@ -106,7 +106,9 @@ class PmsBoardServiceRoomType(models.Model): # Action methods def open_board_lines_form(self): - action = self.env.ref("pms.action_pms_board_service_room_type_view").read()[0] + action = ( + self.env.ref("pms.action_pms_board_service_room_type_view").sudo().read()[0] + ) action["views"] = [ (self.env.ref("pms.pms_board_service_room_type_form").id, "form") ] diff --git a/pms/models/pms_folio.py b/pms/models/pms_folio.py index 834f9237f..a49c88d15 100644 --- a/pms/models/pms_folio.py +++ b/pms/models/pms_folio.py @@ -3,8 +3,11 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging +from itertools import groupby from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError +from odoo.tools import float_is_zero _logger = logging.getLogger(__name__) @@ -25,24 +28,21 @@ class PmsFolio(models.Model): result.append((folio.id, name)) return result - @api.model - def _default_diff_invoicing(self): - """ - If the guest has an invoicing address set, - this method return diff_invoicing = True, else, return False - """ - if "folio_id" in self.env.context: - folio = self.env["pms.folio"].browse([self.env.context["folio_id"]]) - if folio.partner_id.id == folio.partner_invoice_id.id: - return False - return True - @api.model def _get_default_pms_property(self): return ( self.env.user.pms_property_id ) # TODO: Change by property env variable (like company) + def _default_note(self): + return ( + self.env["ir.config_parameter"] + .sudo() + .get_param("account.use_invoice_terms") + and self.env.company.invoice_terms + or "" + ) + # Fields declaration name = fields.Char( string="Folio Number", readonly=True, index=True, default=lambda self: _("New") @@ -78,12 +78,30 @@ class PmsFolio(models.Model): help="Services detail provide to customer and it will " "include in main Invoice.", ) + sale_line_ids = fields.One2many( + "folio.sale.line", + "folio_id", + compute="_compute_sale_line_ids", + compute_sudo=True, + store="True", + ) + invoice_count = fields.Integer( + string="Invoice Count", compute="_compute_get_invoiced", readonly=True + ) company_id = fields.Many2one( "res.company", "Company", required=True, default=lambda self: self.env.company, ) + move_line_ids = fields.Many2many( + "account.move.line", + "payment_folio_rel", + "folio_id", + "move_id", + string="Payments", + readonly=True, + ) analytic_account_id = fields.Many2one( "account.analytic.account", "Analytic Account", @@ -138,8 +156,15 @@ class PmsFolio(models.Model): ondelete="restrict", domain=[("channel_type", "=", "direct")], ) - payment_ids = fields.One2many("account.payment", "folio_id", readonly=True) - # return_ids = fields.One2many("payment.return", "folio_id", readonly=True) + transaction_ids = fields.Many2many( + "payment.transaction", + "folio_transaction_rel", + "folio_id", + "transaction_id", + string="Transactions", + copy=False, + readonly=True, + ) payment_term_id = fields.Many2one( "account.payment.term", string="Payment Terms", @@ -172,9 +197,22 @@ class PmsFolio(models.Model): "account.move", string="Invoices", compute="_compute_get_invoiced", + search="_search_invoice_ids", readonly=True, copy=False, - compute_sudo=True, + ) + payment_state = fields.Selection( + selection=[ + ("not_paid", "Not Paid"), + ("paid", "Paid"), + ("partial", "Partially Paid"), + ], + string="Payment Status", + store=True, + readonly=True, + copy=False, + tracking=True, + compute="_compute_amount", ) partner_invoice_id = fields.Many2one( "res.partner", @@ -289,7 +327,7 @@ class PmsFolio(models.Model): ("no", "Nothing to Invoice"), ], string="Invoice Status", - compute="_compute_get_invoiced", + compute="_compute_get_invoice_status", store=True, readonly=True, default="no", @@ -304,6 +342,12 @@ class PmsFolio(models.Model): advance has not been recorded", ) sequence = fields.Integer(string="Sequence", default=10) + note = fields.Text("Terms and conditions", default=_default_note) + reference = fields.Char( + string="Payment Ref.", + copy=False, + help="The payment communication of this sale order.", + ) # Compute and Search methods @api.depends("reservation_ids", "reservation_ids.state") @@ -313,6 +357,100 @@ class PmsFolio(models.Model): folio.reservation_ids.filtered(lambda a: a.state != "cancelled") ) + @api.depends( + "reservation_ids", + "service_ids", + "service_ids.reservation_id", + "reservation_ids.reservation_line_ids", + "reservation_ids.reservation_line_ids.price", + "reservation_ids.reservation_line_ids.discount", + "reservation_ids.reservation_line_ids.cancel_discount", + ) + def _compute_sale_line_ids(self): + for folio in self: + sale_lines = [(5, 0, 0)] + reservations = folio.reservation_ids + services_without_room = folio.service_ids.filtered( + lambda s: not s.reservation_id + ) + # TODO: Not delete old sale line ids + for reservation in reservations: + sale_lines.append( + ( + 0, + False, + { + "display_type": "line_section", + "name": reservation.name, + }, + ) + ) + group_lines = {} + for line in reservation.reservation_line_ids: + # On resevations the price, and discounts fields are used + # by group, we need pass this in the create line + group_key = ( + reservation.id, + line.price, + line.discount, + line.cancel_discount, + ) + if line.cancel_discount == 100: + continue + discount_factor = 1.0 + for discount in [line.discount, line.cancel_discount]: + discount_factor = discount_factor * ((100.0 - discount) / 100.0) + final_discount = 100.0 - (discount_factor * 100.0) + if group_key not in group_lines: + group_lines[group_key] = { + "reservation_id": reservation.id, + "discount": final_discount, + "price_unit": line.price, + "reservation_line_ids": [(4, line.id)], + } + else: + group_lines[group_key][("reservation_line_ids")].append( + (4, line.id) + ) + for item in group_lines.items(): + sale_lines.append((0, False, item[1])) + for service in reservation.service_ids: + # On service the price, and discounts fields are + # compute in the sale.order.line + sale_lines.append( + ( + 0, + False, + { + "name": service.name, + "service_id": service.id, + }, + ) + ) + if services_without_room: + sale_lines.append( + ( + 0, + False, + { + "display_type": "line_section", + "name": _("Others"), + }, + ) + ) + for service in services_without_room: + sale_lines.append( + ( + 0, + False, + { + "name": service.name, + "service_id": service.id, + }, + ) + ) + folio.sale_line_ids = sale_lines + @api.depends("partner_id", "agency_id") def _compute_pricelist_id(self): for folio in self: @@ -365,100 +503,98 @@ class PmsFolio(models.Model): else: folio.commission = 0 - @api.depends( - "state", "reservation_ids.invoice_status", "service_ids.invoice_status" - ) + @api.depends("sale_line_ids.invoice_lines") def _compute_get_invoiced(self): + # The invoice_ids are obtained thanks to the invoice lines of the SO + # lines, and we also search for possible refunds created directly from + # existing invoices. This is necessary since such a refund is not + # directly linked to the SO. + for order in self: + invoices = order.sale_line_ids.invoice_lines.move_id.filtered( + lambda r: r.move_type in ("out_invoice", "out_refund") + ) + order.move_ids = invoices + order.invoice_count = len(invoices) + + def _search_invoice_ids(self, operator, value): + if operator == "in" and value: + self.env.cr.execute( + """ + SELECT array_agg(so.id) + FROM pms_folio so + JOIN folio_sale_line sol ON sol.folio_id = so.id + JOIN folio_sale_line_invoice_rel soli_rel ON \ + soli_rel.sale_line_ids = sol.id + JOIN account_move_line aml ON aml.id = soli_rel.invoice_line_id + JOIN account_move am ON am.id = aml.move_id + WHERE + am.move_type in ('out_invoice', 'out_refund') AND + am.id = ANY(%s) + """, + (list(value),), + ) + so_ids = self.env.cr.fetchone()[0] or [] + return [("id", "in", so_ids)] + return [ + "&", + ( + "sale_line_ids.invoice_lines.move_id.move_type", + "in", + ("out_invoice", "out_refund"), + ), + ("sale_line_ids.invoice_lines.move_id", operator, value), + ] + + @api.depends("state", "sale_line_ids.invoice_status") + def _compute_get_invoice_status(self): """ Compute the invoice status of a Folio. Possible statuses: - - no: if the Folio is not in status 'sale' or 'done', we - consider that there is nothing to invoice. - This is also the default value if the conditions of no other - status is met. - - to invoice: if any Folio line is 'to invoice', - the whole Folio is 'to invoice' - - invoiced: if all Folio lines are invoiced, the Folio is invoiced. - - The invoice_ids are obtained thanks to the invoice lines of the - Folio lines, and we also search for possible refunds created - directly from existing invoices. This is necessary since such a - refund is not directly linked to the Folio. + - no: if the Folio is in status 'draft', we consider that there is nothing to + invoice. This is also the default value if the conditions of no + other status is met. + - to invoice: if any SO line is 'to invoice', the whole SO is 'to invoice' + - invoiced: if all SO lines are invoiced, the SO is invoiced. + - upselling: if all SO lines are invoiced or upselling, the status is upselling. """ - self.move_ids = False - for folio in self.filtered("pricelist_id"): - move_ids = ( - folio.reservation_ids.mapped("move_line_ids") - .mapped("move_id") - .filtered(lambda r: r.type in ["out_invoice", "out_refund"]) - ) - invoice_ids = ( - folio.service_ids.mapped("move_line_ids") - .mapped("move_id") - .filtered(lambda r: r.type in ["out_invoice", "out_refund"]) - ) - # TODO: Search for invoices which have been 'cancelled' - # (filter_refund = 'modify' in 'account.move.refund') - # use like as origin may contains multiple references - # (e.g. 'SO01, SO02') - refunds = invoice_ids.search( + unconfirmed_orders = self.filtered(lambda so: so.state in ["draft"]) + unconfirmed_orders.invoice_status = "no" + confirmed_orders = self - unconfirmed_orders + if not confirmed_orders: + return + line_invoice_status_all = [ + (d["folio_id"][0], d["invoice_status"]) + for d in self.env["folio.sale.line"].read_group( [ - ("invoice_origin", "like", folio.name), - ("company_id", "=", folio.company_id.id), - ] - ).filtered(lambda r: r.type in ["out_invoice", "out_refund"]) - invoice_ids |= refunds.filtered(lambda r: folio.id in r.folio_ids.ids) - # Search for refunds as well - refund_ids = self.env["account.move"].browse() - if invoice_ids: - for inv in invoice_ids: - refund_ids += refund_ids.search( - [ - ("type", "=", "out_refund"), - ("invoice_origin", "=", inv.number), - ("invoice_origin", "!=", False), - ("journal_id", "=", inv.journal_id.id), - ] - ) - # Ignore the status of the deposit product - deposit_product_id = self.env[ - "sale.advance.payment.inv" - ]._default_product_id() - service_invoice_status = [ - service.invoice_status - for service in folio.service_ids - if service.product_id != deposit_product_id - ] - reservation_invoice_status = [ - reservation.invoice_status for reservation in folio.reservation_ids - ] - - if folio.state not in ("confirm", "done"): - invoice_status = "no" - elif any( - invoice_status == "to invoice" - for invoice_status in service_invoice_status - ) or any( - invoice_status == "to invoice" - for invoice_status in reservation_invoice_status - ): - invoice_status = "to invoice" - elif all( - invoice_status == "invoiced" - for invoice_status in service_invoice_status - ) or any( - invoice_status == "invoiced" - for invoice_status in reservation_invoice_status - ): - invoice_status = "invoiced" - else: - invoice_status = "no" - - folio.update( - { - "move_ids": move_ids.ids + refund_ids.ids, - "invoice_status": invoice_status, - } + ("folio_id", "in", confirmed_orders.ids), + ("is_downpayment", "=", False), + ("display_type", "=", False), + ], + ["folio_id", "invoice_status"], + ["folio_id", "invoice_status"], + lazy=False, ) + ] + for order in confirmed_orders: + line_invoice_status = [ + d[1] for d in line_invoice_status_all if d[0] == order.id + ] + if order.state in ("draft"): + order.invoice_status = "no" + elif any( + invoice_status == "to invoice" for invoice_status in line_invoice_status + ): + order.invoice_status = "to invoice" + elif line_invoice_status and all( + invoice_status == "invoiced" for invoice_status in line_invoice_status + ): + order.invoice_status = "invoiced" + elif line_invoice_status and all( + invoice_status in ("invoiced", "upselling") + for invoice_status in line_invoice_status + ): + order.invoice_status = "upselling" + else: + order.invoice_status = "no" @api.depends("reservation_ids.price_total", "service_ids.price_total") def _compute_amount_all(self): @@ -514,39 +650,67 @@ class PmsFolio(models.Model): ) # TODO: Add return_ids to depends - @api.depends("amount_total", "payment_ids", "reservation_type", "state") + @api.depends( + "amount_total", + "reservation_type", + "state", + "move_line_ids", + "move_line_ids.parent_state", + "sale_line_ids.invoice_lines", + "sale_line_ids.invoice_lines.move_id.payment_state", + ) def _compute_amount(self): - acc_pay_obj = self.env["account.payment"] for record in self: if record.reservation_type in ("staff", "out"): vals = { "pending_amount": 0, "invoices_paid": 0, - # "refund_amount": 0, } record.update(vals) else: - total_inv_refund = 0 - payments = acc_pay_obj.search([("folio_id", "=", record.id)]) - total_paid = sum(pay.amount for pay in payments) - # return_lines = self.env["payment.return.line"].search( - # [ - # ("move_line_ids", "in", payments.mapped("move_line_ids.id")), - # ("return_id.state", "=", "done"), - # ] - # ) - # total_inv_refund = sum( - # pay_return.amount for pay_return in return_lines - # ) + journals = record.pms_property_id._get_payment_methods() + paid_out = 0 + for journal in journals: + paid_out += sum( + self.env["account.move.line"] + .search( + [ + ("folio_ids", "in", record.id), + ( + "account_id", + "in", + tuple( + journal.default_account_id.ids + + journal.payment_debit_account_id.ids + + journal.payment_credit_account_id.ids + ), + ), + ( + "display_type", + "not in", + ("line_section", "line_note"), + ), + ("move_id.state", "!=", "cancel"), + ] + ) + .mapped("balance") + ) total = record.amount_total # REVIEW: Must We ignored services in cancelled folios # pending amount? if record.state == "cancelled": total = total - sum(record.service_ids.mapped("price_total")) + # Compute 'payment_state'. + if total <= paid_out: + payment_state = "paid" + elif paid_out < total: + payment_state = "partial" + else: + payment_state = "not_paid" vals = { - "pending_amount": total - total_paid + total_inv_refund, - "invoices_paid": total_paid, - # "refund_amount": total_inv_refund, + "pending_amount": total - paid_out, + "invoices_paid": paid_out, + "payment_state": payment_state, } record.update(vals) @@ -554,30 +718,13 @@ class PmsFolio(models.Model): def action_pay(self): self.ensure_one() - partner = self.partner_id.id - amount = self.pending_amount - view_id = self.env.ref("pms.account_payment_view_form_folio").id - return { - "name": _("Register Payment"), - "view_type": "form", - "view_mode": "form", - "res_model": "account.payment", - "type": "ir.actions.act_window", - "view_id": view_id, - "context": { - "default_folio_id": self.id, - "default_amount": amount, - "default_payment_type": "inbound", - "default_partner_type": "customer", - "default_partner_id": partner, - "default_communication": self.name, - }, - "target": "new", - } + action = self.env.ref("pms.action_payment_folio").sudo().read()[0] + action["res_id"] = self.id + return action def open_moves_folio(self): invoices = self.mapped("move_ids") - action = self.env.ref("account.action_move_out_invoice_type").read()[0] + action = self.env.ref("account.action_move_out_invoice_type").sudo().read()[0] if len(invoices) > 1: action["domain"] = [("id", "in", invoices.ids)] elif len(invoices) == 1: @@ -685,11 +832,327 @@ class PmsFolio(models.Model): # create an analytic account if at least an expense product # if any([expense_policy != 'no' for expense_policy in - # self.order_line.mapped('product_id.expense_policy')]): + # self.sale_line_ids.mapped('product_id.expense_policy')]): # if not self.analytic_account_id: # self._create_analytic_account() return True + # CHECKIN/OUT PROCESS + + def _compute_checkin_partner_count(self): + for record in self: + if record.reservation_type == "normal" and record.reservation_ids: + filtered_reservs = record.reservation_ids.filtered( + lambda x: x.state != "cancelled" + ) + mapped_checkin_partner = filtered_reservs.mapped( + "checkin_partner_ids.id" + ) + record.checkin_partner_count = len(mapped_checkin_partner) + mapped_checkin_partner_count = filtered_reservs.mapped( + lambda x: (x.adults + x.children) - len(x.checkin_partner_ids) + ) + record.checkin_partner_pending_count = sum(mapped_checkin_partner_count) + + def _prepare_invoice(self): + """ + Prepare the dict of values to create the new invoice for a folio. + This method may be overridden to implement custom invoice generation + (making sure to call super() to establish a clean extension chain). + """ + self.ensure_one() + journal = ( + self.env["account.move"] + .with_context(default_move_type="out_invoice") + ._get_default_journal() + ) + if not journal: + raise UserError( + _("Please define an accounting sales journal for the company %s (%s).") + % (self.company_id.name, self.company_id.id) + ) + + invoice_vals = { + "ref": self.client_order_ref or "", + "move_type": "out_invoice", + "narration": self.note, + "currency_id": self.pricelist_id.currency_id.id, + # 'campaign_id': self.campaign_id.id, + # 'medium_id': self.medium_id.id, + # 'source_id': self.source_id.id, + "invoice_user_id": self.user_id and self.user_id.id, + "partner_id": self.partner_invoice_id.id, + "partner_bank_id": self.company_id.partner_id.bank_ids[:1].id, + "journal_id": journal.id, # company comes from the journal + "invoice_origin": self.name, + "invoice_payment_term_id": self.payment_term_id.id, + "payment_reference": self.reference, + "transaction_ids": [(6, 0, self.transaction_ids.ids)], + "folio_ids": [(6, 0, [self.id])], + "invoice_line_ids": [], + "company_id": self.company_id.id, + } + return invoice_vals + + def action_view_invoice(self): + invoices = self.mapped("move_ids") + action = self.env["ir.actions.actions"]._for_xml_id( + "account.action_move_out_invoice_type" + ) + if len(invoices) > 1: + action["domain"] = [("id", "in", invoices.ids)] + elif len(invoices) == 1: + form_view = [(self.env.ref("account.view_move_form").id, "form")] + if "views" in action: + action["views"] = form_view + [ + (state, view) for state, view in action["views"] if view != "form" + ] + else: + action["views"] = form_view + action["res_id"] = invoices.id + else: + action = {"type": "ir.actions.act_window_close"} + + context = { + "default_move_type": "out_invoice", + } + if len(self) == 1: + context.update( + { + "default_partner_id": self.partner_id.id, + "default_invoice_payment_term_id": self.payment_term_id.id + or self.partner_id.property_payment_term_id.id + or self.env["account.move"] + .default_get(["invoice_payment_term_id"]) + .get("invoice_payment_term_id"), + "default_invoice_origin": self.mapped("name"), + "default_user_id": self.user_id.id, + } + ) + action["context"] = context + return action + + def _get_invoice_grouping_keys(self): + return ["company_id", "partner_id", "currency_id"] + + @api.model + def _nothing_to_invoice_error(self): + msg = _( + """There is nothing to invoice!\n + Reason(s) of this behavior could be: + - You should deliver your products before invoicing them: Click on the "truck" + icon (top-right of your screen) and follow instructions. + - You should modify the invoicing policy of your product: Open the product, + go to the "Sales tab" and modify invoicing policy from "delivered quantities" + to "ordered quantities". + """ + ) + return UserError(msg) + + def _create_invoices( + self, + grouped=False, + final=False, + date=None, + lines_to_invoice=False, + ): + """ + Create the invoice associated to the Folio. + :param grouped: if True, invoices are grouped by Folio id. + If False, invoices are grouped by + (partner_invoice_id, currency) + :param final: if True, refunds will be generated if necessary + :returns: list of created invoices + :lines_to_invoice: invoice specific lines, if False, invoice all + """ + if not self.env["account.move"].check_access_rights("create", False): + try: + self.check_access_rights("write") + self.check_access_rule("write") + except AccessError: + return self.env["account.move"] + + # 1) Create invoices. + if not lines_to_invoice: + lines_to_invoice = self.sale_line_ids + invoice_vals_list = self.get_invoice_vals_list(final, lines_to_invoice) + + if not invoice_vals_list: + raise self._nothing_to_invoice_error() + + # 2) Manage 'grouped' parameter: group by (partner_id, currency_id). + if not grouped: + new_invoice_vals_list = [] + invoice_grouping_keys = self._get_invoice_grouping_keys() + for _grouping_keys, invoices in groupby( + invoice_vals_list, + key=lambda x: [ + x.get(grouping_key) for grouping_key in invoice_grouping_keys + ], + ): + origins = set() + payment_refs = set() + refs = set() + ref_invoice_vals = None + for invoice_vals in invoices: + if not ref_invoice_vals: + ref_invoice_vals = invoice_vals + else: + ref_invoice_vals["invoice_line_ids"] += invoice_vals[ + "invoice_line_ids" + ] + origins.add(invoice_vals["invoice_origin"]) + payment_refs.add(invoice_vals["payment_reference"]) + refs.add(invoice_vals["ref"]) + ref_invoice_vals.update( + { + "ref": ", ".join(refs)[:2000], + "invoice_origin": ", ".join(origins), + "payment_reference": len(payment_refs) == 1 + and payment_refs.pop() + or False, + } + ) + new_invoice_vals_list.append(ref_invoice_vals) + invoice_vals_list = new_invoice_vals_list + + # 3) Create invoices. + + # As part of the invoice creation, we make sure the + # sequence of multiple SO do not interfere + # in a single invoice. Example: + # Folio 1: + # - Section A (sequence: 10) + # - Product A (sequence: 11) + # Folio 2: + # - Section B (sequence: 10) + # - Product B (sequence: 11) + # + # If Folio 1 & 2 are grouped in the same invoice, + # the result will be: + # - Section A (sequence: 10) + # - Section B (sequence: 10) + # - Product A (sequence: 11) + # - Product B (sequence: 11) + # + # Resequencing should be safe, however we resequence only + # if there are less invoices than orders, meaning a grouping + # might have been done. This could also mean that only a part + # of the selected SO are invoiceable, but resequencing + # in this case shouldn't be an issue. + if len(invoice_vals_list) < len(self): + FolioSaleLine = self.env["folio.sale.line"] + for invoice in invoice_vals_list: + sequence = 1 + for line in invoice["invoice_line_ids"]: + line[2]["sequence"] = FolioSaleLine._get_invoice_line_sequence( + new=sequence, old=line[2]["sequence"] + ) + sequence += 1 + + # Manage the creation of invoices in sudo because + # a salesperson must be able to generate an invoice from a + # sale order without "billing" access rights. + # However, he should not be able to create an invoice from scratch. + moves = ( + self.env["account.move"] + .sudo() + .with_context(default_move_type="out_invoice") + .create(invoice_vals_list) + ) + + # 4) Some moves might actually be refunds: convert + # them if the total amount is negative + # We do this after the moves have been created + # since we need taxes, etc. to know if the total + # is actually negative or not + if final: + moves.sudo().filtered( + lambda m: m.amount_total < 0 + ).action_switch_invoice_into_refund_credit_note() + for move in moves: + move.message_post_with_view( + "mail.message_origin_link", + values={ + "self": move, + "origin": move.line_ids.mapped("folio_line_ids.folio_id"), + }, + subtype_id=self.env.ref("mail.mt_note").id, + ) + return moves + + def get_invoice_vals_list(self, final=False, lines_to_invoice=False): + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + invoice_vals_list = [] + invoice_item_sequence = 0 + for order in self: + order = order.with_company(order.company_id) + current_section_vals = None + down_payments = order.env["folio.sale.line"] + + # Invoice values. + invoice_vals = order._prepare_invoice() + + # Invoice line values (keep only necessary sections). + invoice_lines_vals = [] + for line in order.sale_line_ids.filtered( + lambda l: l.id in lines_to_invoice.ids + ): + if line.display_type == "line_section": + current_section_vals = line._prepare_invoice_line( + sequence=invoice_item_sequence + 1 + ) + continue + if line.display_type != "line_note" and float_is_zero( + line.qty_to_invoice, precision_digits=precision + ): + continue + if ( + line.qty_to_invoice > 0 + or (line.qty_to_invoice < 0 and final) + or line.display_type == "line_note" + ): + if line.is_downpayment: + down_payments += line + continue + if current_section_vals: + invoice_item_sequence += 1 + invoice_lines_vals.append(current_section_vals) + current_section_vals = None + invoice_item_sequence += 1 + prepared_line = line._prepare_invoice_line( + sequence=invoice_item_sequence + ) + invoice_lines_vals.append(prepared_line) + + # If down payments are present in SO, group them under common section + if down_payments: + invoice_item_sequence += 1 + down_payments_section = order._prepare_down_payment_section_line( + sequence=invoice_item_sequence + ) + invoice_lines_vals.append(down_payments_section) + for down_payment in down_payments: + invoice_item_sequence += 1 + invoice_down_payment_vals = down_payment._prepare_invoice_line( + sequence=invoice_item_sequence + ) + invoice_lines_vals.append(invoice_down_payment_vals) + + if not any( + new_line["display_type"] is False for new_line in invoice_lines_vals + ): + raise self._nothing_to_invoice_error() + + invoice_vals["invoice_line_ids"] = [ + (0, 0, invoice_line_id) for invoice_line_id in invoice_lines_vals + ] + + invoice_vals_list.append(invoice_vals) + return invoice_vals_list + def _get_tax_amount_by_group(self): self.ensure_one() res = {} @@ -730,3 +1193,25 @@ class PmsFolio(models.Model): for record in self: if record.agency_id and record.channel_type_id: raise models.ValidationError(_("There must be only one sale channel")) + + @api.model + def _prepare_down_payment_section_line(self, **optional_values): + """ + Prepare the dict of values to create a new down + payment section for a sales order line. + :param optional_values: any parameter that should + be added to the returned down payment section + """ + down_payments_section_line = { + "display_type": "line_section", + "name": _("Down Payments"), + "product_id": False, + "product_uom_id": False, + "quantity": 0, + "discount": 0, + "price_unit": 0, + "account_id": False, + } + if optional_values: + down_payments_section_line.update(optional_values) + return down_payments_section_line diff --git a/pms/models/pms_property.py b/pms/models/pms_property.py index 38b8ff313..9ac5876df 100644 --- a/pms/models/pms_property.py +++ b/pms/models/pms_property.py @@ -108,3 +108,22 @@ class PmsProperty(models.Model): date = date.astimezone(pytz.utc) date = date.replace(tzinfo=None) return date + + def _get_payment_methods(self): + self.ensure_one() + payment_methods = self.env["account.journal"].search( + [ + "&", + ("type", "in", ["cash", "bank"]), + "|", + ("pms_property_ids", "in", self.id), + "|", + "&", + ("pms_property_ids", "=", False), + ("company_id", "=", self.company_id.id), + "&", + ("pms_property_ids", "=", False), + ("company_id", "=", False), + ] + ) + return payment_methods diff --git a/pms/models/pms_reservation.py b/pms/models/pms_reservation.py index f54bfaf06..007e11a2d 100644 --- a/pms/models/pms_reservation.py +++ b/pms/models/pms_reservation.py @@ -84,20 +84,6 @@ class PmsReservation(models.Model): segmentation_ids = folio.segmentation_ids return segmentation_ids - @api.model - def _default_diff_invoicing(self): - """ - If the guest has an invoicing address set, - this method return diff_invoicing = True, else, return False - """ - if "reservation_id" in self.env.context: - reservation = self.env["pms.reservation"].browse( - [self.env.context["reservation_id"]] - ) - if reservation.partner_id.id == reservation.partner_invoice_id.id: - return False - return True - # Fields declaration name = fields.Text( "Reservation Description", @@ -113,6 +99,7 @@ class PmsReservation(models.Model): string="Room", ondelete="restrict", domain="[('id', 'in', allowed_room_ids)]", + copy=False, ) allowed_room_ids = fields.Many2many( "pms.room", @@ -124,6 +111,7 @@ class PmsReservation(models.Model): string="Folio", tracking=True, ondelete="restrict", + copy=False, ) board_service_room_id = fields.Many2one( "pms.board.service.room.type", @@ -137,6 +125,7 @@ class PmsReservation(models.Model): compute="_compute_room_type_id", store=True, readonly=False, + copy=False, ) partner_id = fields.Many2one( "res.partner", @@ -178,6 +167,7 @@ class PmsReservation(models.Model): compute="_compute_reservation_line_ids", store=True, readonly=False, + copy=False, ) service_ids = fields.One2many( "pms.service", @@ -212,6 +202,7 @@ class PmsReservation(models.Model): compute="_compute_checkin_partner_ids", store=True, readonly=False, + copy=False, ) count_pending_arrival = fields.Integer( "Pending Arrival", @@ -249,13 +240,16 @@ class PmsReservation(models.Model): ) currency_id = fields.Many2one( "res.currency", - related="pricelist_id.currency_id", - string="Currency", + depends=["pricelist_id"], + store=True, readonly=True, ) tax_ids = fields.Many2many( "account.tax", string="Taxes", + compute="_compute_tax_ids", + readonly="False", + store=True, ondelete="restrict", domain=["|", ("active", "=", False), ("active", "=", True)], ) @@ -267,9 +261,10 @@ class PmsReservation(models.Model): string="Invoice Lines", copy=False, ) - analytic_tag_ids = fields.Many2many("account.analytic.tag", string="Analytic Tags") localizator = fields.Char( - string="Localizator", compute="_compute_localizator", store=True + string="Localizator", + compute="_compute_localizator", + store=True, ) adults = fields.Integer( "Adults", @@ -282,7 +277,6 @@ class PmsReservation(models.Model): children_occupying = fields.Integer( string="Children occupying", ) - children = fields.Integer( "Children", readonly=False, @@ -315,23 +309,26 @@ class PmsReservation(models.Model): compute="_compute_splitted", store=True, ) - rooms = fields.Char( string="Room/s", compute="_compute_rooms", store=True, tracking=True, ) - credit_card_details = fields.Text(related="folio_id.credit_card_details") cancelled_reason = fields.Selection( [("late", "Late"), ("intime", "In time"), ("noshow", "No Show")], string="Cause of cancelled", tracking=True, + copy=False, ) out_service_description = fields.Text("Cause of out of service") - checkin = fields.Date("Check In", required=True, default=_get_default_checkin) - checkout = fields.Date("Check Out", required=True, default=_get_default_checkout) + checkin = fields.Date( + "Check In", required=True, default=_get_default_checkin, copy=False + ) + checkout = fields.Date( + "Check Out", required=True, default=_get_default_checkout, copy=False + ) arrival_hour = fields.Char( "Arrival Hour", default=_get_default_arrival_hour, @@ -350,11 +347,6 @@ class PmsReservation(models.Model): "Exact Departure", compute="_compute_checkout_datetime", ) - # TODO: As checkin_partner_count is a computed field, it can't not - # be used in a domain filer Non-stored field - # pms.reservation.checkin_partner_count cannot be searched - # searching on a computed field can also be enabled by setting the - # search parameter. The value is a method name returning a Domains checkin_partner_count = fields.Integer( "Checkin counter", compute="_compute_checkin_partner_count" ) @@ -363,8 +355,16 @@ class PmsReservation(models.Model): compute="_compute_checkin_partner_count", search="_search_checkin_partner_pending", ) - overbooking = fields.Boolean("Is Overbooking", default=False) - reselling = fields.Boolean("Is Reselling", default=False) + overbooking = fields.Boolean( + "Is Overbooking", + default=False, + copy=False, + ) + reselling = fields.Boolean( + "Is Reselling", + default=False, + copy=False, + ) nights = fields.Integer("Nights", compute="_compute_nights", store=True) origin = fields.Char("Origin", compute="_compute_origin", store=True) detail_origin = fields.Char( @@ -380,11 +380,13 @@ class PmsReservation(models.Model): string="Internal Partner Notes", related="partner_id.comment" ) folio_internal_comment = fields.Text( - string="Internal Folio Notes", related="folio_id.internal_comment" + string="Internal Folio Notes", + related="folio_id.internal_comment", ) preconfirm = fields.Boolean("Auto confirm to Save", default=True) invoice_status = fields.Selection( [ + ("upselling", "Upselling Opportunity"), ("invoiced", "Fully Invoiced"), ("to invoice", "To Invoice"), ("no", "Nothing to Invoice"), @@ -396,19 +398,39 @@ class PmsReservation(models.Model): default="no", ) qty_to_invoice = fields.Float( - compute="_compute_get_to_invoice_qty", - string="To Invoice", + compute="_compute_qty_to_invoice", + string="To Invoice Quantity", store=True, readonly=True, digits=("Product Unit of Measure"), ) qty_invoiced = fields.Float( - compute="_compute_get_invoice_qty", - string="Invoiced", + compute="_compute_qty_invoiced", + string="Invoiced Quantity", store=True, readonly=True, digits=("Product Unit of Measure"), ) + untaxed_amount_invoiced = fields.Monetary( + "Untaxed Invoiced Amount", + compute="_compute_untaxed_amount_invoiced", + compute_sudo=True, + store=True, + ) + untaxed_amount_to_invoice = fields.Monetary( + "Untaxed Amount To Invoice", + compute="_compute_untaxed_amount_to_invoice", + compute_sudo=True, + store=True, + ) + analytic_tag_ids = fields.Many2many( + "account.analytic.tag", + string="Analytic Tags", + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + ) + analytic_line_ids = fields.One2many( + "account.analytic.line", "so_line", string="Analytic lines" + ) price_subtotal = fields.Monetary( string="Subtotal", readonly=True, @@ -443,6 +465,7 @@ class PmsReservation(models.Model): string="Discount (€)", digits=("Discount"), compute="_compute_discount", + readonly=False, store=True, ) @@ -532,17 +555,18 @@ class PmsReservation(models.Model): ) reservation.allowed_room_ids = rooms_available - @api.depends("reservation_type", "agency_id") + @api.depends("reservation_type", "agency_id", "folio_id") def _compute_partner_id(self): for reservation in self: if reservation.reservation_type == "out": reservation.partner_id = reservation.pms_property_id.partner_id.id - if reservation.folio_id: - reservation.partner_id = reservation.folio_id.partner_id - else: - reservation.partner_id = False - if not reservation.partner_id and reservation.agency_id: - reservation.partner_id = reservation.agency_id + elif not reservation.partner_id: + if reservation.folio_id: + reservation.partner_id = reservation.folio_id.partner_id + elif reservation.agency_id: + reservation.partner_id = reservation.agency_id + else: + reservation.partner_id = False @api.depends("partner_id") def _compute_partner_invoice_id(self): @@ -851,11 +875,10 @@ class PmsReservation(models.Model): line.invoice_status = "no" @api.depends("qty_invoiced", "nights", "folio_id.state") - def _compute_get_to_invoice_qty(self): + def _compute_qty_to_invoice(self): """ - Compute the quantity to invoice. If the invoice policy is order, - the quantity to invoice is calculated from the ordered quantity. - Otherwise, the quantity delivered is used. + Compute the quantity to invoice. The quantity to invoice is + calculated from the nights quantity. """ for line in self: if line.folio_id.state not in ["draft"]: @@ -863,25 +886,138 @@ class PmsReservation(models.Model): else: line.qty_to_invoice = 0 - @api.depends("move_line_ids.move_id.state", "move_line_ids.quantity") - def _compute_get_invoice_qty(self): + @api.depends( + "move_line_ids.move_id.state", + "move_line_ids.quantity", + "untaxed_amount_to_invoice", + ) + def _compute_qty_invoiced(self): """ Compute the quantity invoiced. If case of a refund, the quantity invoiced is decreased. We must check day per day and sum or decreased on 1 unit per invoice_line """ - for line in self: + for record in self: qty_invoiced = 0.0 - for day in line.reservation_line_ids: - invoice_lines = day.move_line_ids.filtered( + for line in record.reservation_line_ids: + invoice_lines = line.move_line_ids.filtered( lambda r: r.move_id.state != "cancel" ) qty_invoiced += len( - invoice_lines.filtered(lambda r: r.move_id.type == "out_invoice") + invoice_lines.filtered( + lambda r: r.move_id.move_type == "out_invoice" + ) ) - len( - invoice_lines.filtered(lambda r: r.move_id.type == "out_refund") + invoice_lines.filtered( + lambda r: r.move_id.move_type == "out_refund" + ) ) - line.qty_invoiced = qty_invoiced + record.qty_invoiced = qty_invoiced + + @api.depends( + "move_line_ids", + "move_line_ids.price_total", + "move_line_ids.move_id.state", + "move_line_ids.move_id.move_type", + ) + def _compute_untaxed_amount_invoiced(self): + """Compute the untaxed amount already invoiced from the reservation, + taking the refund attached + the reservation into account. This amount is computed as + SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) + where + `inv_line` is a customer invoice line linked to the reservation + `ref_line` is a customer credit note (refund) + line linked to the reservation + """ + for line in self: + amount_invoiced = 0.0 + for invoice_line in line.move_line_ids: + if invoice_line.move_id.state == "posted": + invoice_date = ( + invoice_line.move_id.invoice_date or fields.Date.today() + ) + if invoice_line.move_id.move_type == "out_invoice": + amount_invoiced += invoice_line.currency_id._convert( + invoice_line.price_subtotal, + line.currency_id, + line.company_id, + invoice_date, + ) + elif invoice_line.move_id.move_type == "out_refund": + amount_invoiced -= invoice_line.currency_id._convert( + invoice_line.price_subtotal, + line.currency_id, + line.company_id, + invoice_date, + ) + line.untaxed_amount_invoiced = amount_invoiced + + @api.depends( + "state", "discount", "price_total", "room_type_id", "untaxed_amount_invoiced" + ) + def _compute_untaxed_amount_to_invoice(self): + """Total of remaining amount to invoice on the reservation (taxes excl.) as + total_sol - amount already invoiced + + Note: Draft invoice are ignored on purpose, the 'to invoice' amount should + come only from the reservation. + """ + for line in self: + amount_to_invoice = 0.0 + if line.state not in ["draft"]: + price_subtotal = 0.0 + price_subtotal = line.price_subtotal + if len(line.tax_ids.filtered(lambda tax: tax.price_include)) > 0: + # As included taxes are not excluded from the computed subtotal, + # `compute_all()` method + # has to be called to retrieve the subtotal without them. + price_subtotal = line.tax_ids.compute_all( + price_subtotal, + currency=line.currency_id, + quantity=line.nights, + product=line.room_type_id.product_id, + )["total_excluded"] + + if any( + line.move_line_ids.mapped(lambda i: i.discount != line.discount) + ): + # In case of re-invoicing with different discount we + # try to calculate manually the + # remaining amount to invoice + amount = 0 + for move in line.move_line_ids: + if ( + len(move.tax_ids.filtered(lambda tax: tax.price_include)) + > 0 + ): + amount += move.tax_ids.compute_all( + move.currency_id._convert( + move.price_unit, + line.currency_id, + line.company_id, + move.date or fields.Date.today(), + round=False, + ) + * move.quantity + )["total_excluded"] + else: + amount += ( + move.currency_id._convert( + move.price_unit, + line.currency_id, + line.company_id, + move.date or fields.Date.today(), + round=False, + ) + * move.quantity + ) + + amount_to_invoice = max(price_subtotal - amount, 0) + else: + amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced + + line.untaxed_amount_to_invoice = amount_to_invoice @api.depends("reservation_line_ids") def _compute_nights(self): @@ -1033,33 +1169,10 @@ class PmsReservation(models.Model): if record.agency_id and not record.agency_id.is_agency: raise ValidationError(_("booking agency with wrong configuration: ")) - # @api.constrains("reservation_type", "partner_id") - # def _check_partner_reservation(self): - # for reservation in self: - # if ( - # reservation.reservation_type == "out" - # and reservation.partner_id.id != \ - # reservation.pms_property_id.partner_id.id - # ): - # raise models.ValidationError( - # _("The partner on out reservations must be a property partner") - # ) - - # @api.constrains("closure_reason_id", "reservation_type") - # def _check_clousure_reservation(self): - # for reservation in self: - # if reservation.closure_reason_id and \ - # reservation.reservation_type != "out": - # raise models.ValidationError( - # _("Only the out reservations can has a clousure reason") - # ) - - # self._compute_tax_ids() TODO: refact - # Action methods def open_folio(self): - action = self.env.ref("pms.open_pms_folio1_form_tree_all").read()[0] + action = self.env.ref("pms.open_pms_folio1_form_tree_all").sudo().read()[0] if self.folio_id: action["views"] = [(self.env.ref("pms.pms_folio_view_form").id, "form")] action["res_id"] = self.folio_id.id @@ -1068,7 +1181,7 @@ class PmsReservation(models.Model): return action def open_reservation_form(self): - action = self.env.ref("pms.open_pms_reservation_form_tree_all").read()[0] + action = self.env.ref("pms.open_pms_reservation_form_tree_all").sudo().read()[0] action["views"] = [(self.env.ref("pms.pms_reservation_view_form").id, "form")] action["res_id"] = self.id return action @@ -1146,6 +1259,19 @@ class PmsReservation(models.Model): result.append((res.id, name)) return result + def copy_data(self, default=None): + rooms_available = self.env["pms.room.type.availability.plan"].rooms_available( + self.checkin, + self.checkout, + room_type_id=self.room_type_id.id, + pricelist=self.pricelist_id.id, + ) + if self.preferred_room_id.id in rooms_available.ids: + default["preferred_room_id"] = self.preferred_room_id.id + if self.room_type_id.id in rooms_available.mapped("room_type_id.id"): + default["room_type_id"] = self.room_type_id.id + return super(PmsReservation, self).copy_data(default) + @api.model def create(self, vals): if "folio_id" in vals: @@ -1405,19 +1531,15 @@ class PmsReservation(models.Model): if reservation.checkout_datetime <= fields.Datetime.now(): reservations.state = "no_checkout" - def unify(self): - # TODO - return True - + @api.depends("room_type_id") def _compute_tax_ids(self): for record in self: - # If company_id is set, always filter taxes by the company - folio = record.folio_id or self.env.context.get("default_folio_id") + record = record.with_company(record.company_id) product = self.env["product.product"].browse( record.room_type_id.product_id.id ) record.tax_ids = product.taxes_id.filtered( - lambda r: not record.company_id or r.company_id == folio.company_id + lambda t: t.company_id == record.env.company ) @api.depends("reservation_line_ids", "reservation_line_ids.room_id") diff --git a/pms/models/pms_reservation_line.py b/pms/models/pms_reservation_line.py index c10fa2da5..2dcb772af 100644 --- a/pms/models/pms_reservation_line.py +++ b/pms/models/pms_reservation_line.py @@ -65,6 +65,11 @@ class PmsReservationLine(models.Model): store=True, readonly=False, ) + invoiced = fields.Boolean( + string="Invoiced", + compute="_compute_invoiced", + store=True, + ) cancel_discount = fields.Float( string="Cancel Discount (%)", digits=("Discount"), @@ -116,7 +121,7 @@ class PmsReservationLine(models.Model): room_type_id=reservation.room_type_id.id if not free_room_select else False, - current_lines=line._origin.reservation_id.reservation_line_ids.ids, + current_lines=line.reservation_id.reservation_line_ids.ids, pricelist=line.reservation_id.pricelist_id.id, ) # if there is availability for the entire stay @@ -282,8 +287,6 @@ class PmsReservationLine(models.Model): line.reservation_id.company_id, ) # TODO: Out of service 0 amount - else: - line.price = line._origin.price @api.depends("reservation_id.state", "reservation_id.overbooking") def _compute_occupies_availability(self): @@ -315,6 +318,18 @@ class PmsReservationLine(models.Model): return True return False + @api.depends("move_line_ids", "move_line_ids.move_id.state") + def _compute_invoiced(self): + for line in self: + qty_invoiced = 0 + for invoice_line in line.move_line_ids: + if invoice_line.move_id.state != "cancel": + if invoice_line.move_id.move_type == "out_invoice": + qty_invoiced += 1 + elif invoice_line.move_id.move_type == "out_refund": + qty_invoiced -= 1 + line.invoiced = False if qty_invoiced < 1 else True + # TODO: Refact method and allowed cancelled single days @api.depends("reservation_id.cancelled_reason") def _compute_cancel_discount(self): diff --git a/pms/models/pms_room.py b/pms/models/pms_room.py index 2dd600708..c2d780b68 100644 --- a/pms/models/pms_room.py +++ b/pms/models/pms_room.py @@ -30,8 +30,8 @@ class PmsRoom(models.Model): name = fields.Char("Room Name", required=True) pms_property_id = fields.Many2one( "pms.property", - store=True, - readonly=True, + required=True, + ondelete="restrict", ) room_type_id = fields.Many2one( "pms.room.type", "Property Room Type", required=True, ondelete="restrict" diff --git a/pms/models/pms_service.py b/pms/models/pms_service.py index 6ff3facee..ce4e59df3 100644 --- a/pms/models/pms_service.py +++ b/pms/models/pms_service.py @@ -424,14 +424,20 @@ class PmsService(models.Model): qty_invoiced = 0.0 for invoice_line in line.move_line_ids: if invoice_line.move_id.state != "cancel": - if invoice_line.move_id.type == "out_invoice": - qty_invoiced += invoice_line.uom_id._compute_quantity( - invoice_line.quantity, line.product_id.uom_id - ) - elif invoice_line.move_id.type == "out_refund": - qty_invoiced -= invoice_line.uom_id._compute_quantity( + if invoice_line.move_id.move_type == "out_invoice": + qty_invoiced += invoice_line.product_uom_id._compute_quantity( invoice_line.quantity, line.product_id.uom_id ) + elif invoice_line.move_id.move_type == "out_refund": + if ( + not line.is_downpayment + or line.untaxed_amount_to_invoice == 0 + ): + qty_invoiced -= ( + invoice_line.product_uom_id._compute_quantity( + invoice_line.quantity, line.product_id.uom_id + ) + ) line.qty_invoiced = qty_invoiced @api.depends("product_qty", "qty_to_invoice", "qty_invoiced") @@ -496,7 +502,7 @@ class PmsService(models.Model): # Action methods def open_service_ids(self): - action = self.env.ref("pms.action_pms_services_form").read()[0] + action = self.env.ref("pms.action_pms_services_form").sudo().read()[0] action["views"] = [(self.env.ref("pms.pms_service_view_form").id, "form")] action["res_id"] = self.id action["target"] = "new" diff --git a/pms/models/product_pricelist.py b/pms/models/product_pricelist.py index 7e3586862..3fbbf7799 100644 --- a/pms/models/product_pricelist.py +++ b/pms/models/product_pricelist.py @@ -1,7 +1,11 @@ # Copyright 2017 Alexandre Díaz, Pablo Quesada, Darío Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + from odoo import fields, models +_logger = logging.getLogger(__name__) + class ProductPricelist(models.Model): """Before creating a 'daily' pricelist, you need to consider the following: diff --git a/pms/report/pms_folio_templates.xml b/pms/report/pms_folio_templates.xml index dd0f15b47..89128db3b 100644 --- a/pms/report/pms_folio_templates.xml +++ b/pms/report/pms_folio_templates.xml @@ -269,7 +269,7 @@
- + diff --git a/pms/views/account_payment_views.xml b/pms/views/account_payment_views.xml index 275ceb7b9..d28522193 100644 --- a/pms/views/account_payment_views.xml +++ b/pms/views/account_payment_views.xml @@ -6,211 +6,8 @@ - - - - - account.payment.folio.form - account.payment - 20 - -
-
-
- -
- - - - - -
- -
-

- -

-
- - - - - - - - - - - - - - - - -
-
-
-
- - -
-
-
-
- - account.payment.folio.tree - account.payment - 20 - - - - - - - - - - - - - +
+ +

@@ -186,6 +217,7 @@ + @@ -193,6 +225,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - diff --git a/pms/views/pms_reservation_views.xml b/pms/views/pms_reservation_views.xml index 735861ff3..69a28ad04 100644 --- a/pms/views/pms_reservation_views.xml +++ b/pms/views/pms_reservation_views.xml @@ -402,6 +402,10 @@ + - @@ -46,6 +41,15 @@ + + + + + diff --git a/pms/wizards/__init__.py b/pms/wizards/__init__.py index e637b53e9..443562250 100644 --- a/pms/wizards/__init__.py +++ b/pms/wizards/__init__.py @@ -3,3 +3,6 @@ from . import wizard_massive_changes from . import wizard_advanced_filters from . import wizard_folio from . import wizard_folio_availability +from . import folio_make_invoice_advance +from . import wizard_invoice_filter_days +from . import wizard_payment_folio diff --git a/pms/wizards/folio_make_invoice_advance.py b/pms/wizards/folio_make_invoice_advance.py new file mode 100644 index 000000000..9f9dfad19 --- /dev/null +++ b/pms/wizards/folio_make_invoice_advance.py @@ -0,0 +1,288 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import time + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class FolioAdvancePaymentInv(models.TransientModel): + _name = "folio.advance.payment.inv" + _description = "Folio Advance Payment Invoice" + + @api.model + def _count(self): + return len(self._context.get("active_ids", [])) + + @api.model + def _default_product_id(self): + product_id = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("sale.default_deposit_product_id") + ) + return self.env["product.product"].browse(int(product_id)).exists() + + @api.model + def _default_deposit_account_id(self): + return self._default_product_id()._get_product_accounts()["income"] + + @api.model + def _default_deposit_taxes_id(self): + return self._default_product_id().taxes_id + + @api.model + def _default_has_down_payment(self): + if self._context.get("active_model") == "pms.folio" and self._context.get( + "active_id", False + ): + folio = self.env["pms.folio"].browse(self._context.get("active_id")) + return folio.sale_line_ids.filtered(lambda line: line.is_downpayment) + + return False + + @api.model + def _default_currency_id(self): + if self._context.get("active_model") == "pms.folio" and self._context.get( + "active_id", False + ): + sale_order = self.env["pms.folio"].browse(self._context.get("active_id")) + return sale_order.currency_id + + advance_payment_method = fields.Selection( + [ + ("delivered", "Regular invoice"), + ("percentage", "Down payment (percentage)"), + ("fixed", "Down payment (fixed amount)"), + ], + string="Create Invoice", + default="delivered", + required=True, + help="A standard invoice is issued with all the order \ + lines ready for invoicing, \ + according to their invoicing policy \ + (based on ordered or delivered quantity).", + ) + bill_services = fields.Boolean("Bill Services", default=True) + bill_rooms = fields.Boolean("Bill Rooms", default=True) + deduct_down_payments = fields.Boolean("Deduct down payments", default=True) + has_down_payments = fields.Boolean( + "Has down payments", default=_default_has_down_payment, readonly=True + ) + product_id = fields.Many2one( + "product.product", + string="Down Payment Product", + domain=[("type", "=", "service")], + default=_default_product_id, + ) + count = fields.Integer(default=_count, string="Order Count") + amount = fields.Float( + "Down Payment Amount", + digits="Account", + help="The percentage of amount to be invoiced in advance, taxes excluded.", + ) + currency_id = fields.Many2one( + "res.currency", string="Currency", default=_default_currency_id + ) + fixed_amount = fields.Monetary( + "Down Payment Amount (Fixed)", + help="The fixed amount to be invoiced in advance, taxes excluded.", + ) + deposit_account_id = fields.Many2one( + "account.account", + string="Income Account", + domain=[("deprecated", "=", False)], + help="Account used for deposits", + default=_default_deposit_account_id, + ) + deposit_taxes_id = fields.Many2many( + "account.tax", + string="Customer Taxes", + help="Taxes used for deposits", + default=_default_deposit_taxes_id, + ) + + @api.onchange("advance_payment_method") + def onchange_advance_payment_method(self): + if self.advance_payment_method == "percentage": + amount = self.default_get(["amount"]).get("amount") + return {"value": {"amount": amount}} + return {} + + def _prepare_invoice_values(self, order, name, amount, line): + invoice_vals = { + "ref": order.client_order_ref, + "move_type": "out_invoice", + "invoice_origin": order.name, + "invoice_user_id": order.user_id.id, + "narration": order.note, + "partner_id": order.partner_invoice_id.id, + "currency_id": order.pricelist_id.currency_id.id, + "payment_reference": order.reference, + "invoice_payment_term_id": order.payment_term_id.id, + "partner_bank_id": order.company_id.partner_id.bank_ids[:1].id, + # 'campaign_id': order.campaign_id.id, + # 'medium_id': order.medium_id.id, + # 'source_id': order.source_id.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": name, + "price_unit": amount, + "quantity": 1.0, + "product_id": self.product_id.id, + "product_uom_id": line.product_uom.id, + "tax_ids": [(6, 0, line.tax_ids.ids)], + "folio_line_ids": [(6, 0, [line.id])], + "analytic_tag_ids": [(6, 0, line.analytic_tag_ids.ids)], + "analytic_account_id": order.analytic_account_id.id or False, + }, + ) + ], + } + + return invoice_vals + + def _get_advance_details(self, order): + context = {"lang": order.partner_id.lang} + if self.advance_payment_method == "percentage": + amount = order.amount_untaxed * self.amount / 100 + name = _("Down payment of %s%%") % (self.amount) + else: + amount = self.fixed_amount + name = _("Down Payment") + del context + + return amount, name + + def _create_invoice(self, order, line, amount): + if (self.advance_payment_method == "percentage" and self.amount <= 0.00) or ( + self.advance_payment_method == "fixed" and self.fixed_amount <= 0.00 + ): + raise UserError(_("The value of the down payment amount must be positive.")) + + amount, name = self._get_advance_details(order) + + invoice_vals = self._prepare_invoice_values(order, name, amount, line) + + if order.fiscal_position_id: + invoice_vals["fiscal_position_id"] = order.fiscal_position_id.id + invoice = ( + self.env["account.move"].sudo().create(invoice_vals).with_user(self.env.uid) + ) + invoice.message_post_with_view( + "mail.message_origin_link", + values={"self": invoice, "origin": order}, + subtype_id=self.env.ref("mail.mt_note").id, + ) + return invoice + + def _prepare_line(self, order, analytic_tag_ids, tax_ids, amount): + context = {"lang": order.partner_id.lang} + so_values = { + "name": _("Down Payment: %s") % (time.strftime("%m %Y"),), + "price_unit": amount, + "product_uom_qty": 0.0, + "folio_id": order.id, + "discount": 0.0, + "product_uom": self.product_id.uom_id.id, + "product_id": self.product_id.id, + "analytic_tag_ids": analytic_tag_ids, + "tax_ids": [(6, 0, tax_ids)], + "is_downpayment": True, + "sequence": order.sale_line_ids + and order.sale_line_ids[-1].sequence + 1 + or 10, + } + del context + return so_values + + def create_invoices(self): + folios = self.env["pms.folio"].browse(self._context.get("active_ids", [])) + + if self.advance_payment_method == "delivered": + lines_to_invoice = self._get_lines_to_invoice( + folios=folios, + bill_services=self.bill_services, + bill_rooms=self.bill_rooms, + ) + folios._create_invoices( + final=self.deduct_down_payments, + lines_to_invoice=lines_to_invoice, + ) + else: + # Create deposit product if necessary + if not self.product_id: + vals = self._prepare_deposit_product() + self.product_id = self.env["product.product"].create(vals) + self.env["ir.config_parameter"].sudo().set_param( + "sale.default_deposit_product_id", self.product_id.id + ) + + sale_line_obj = self.env["folio.sale.line"] + for order in folios: + amount, name = self._get_advance_details(order) + + if self.product_id.invoice_policy != "order": + raise UserError( + _( + """The product used to invoice a down payment should + have an invoice policy set to "Ordered quantities". + Please update your deposit product to be able + to create a deposit invoice.""" + ) + ) + if self.product_id.type != "service": + raise UserError( + _( + """The product used to invoice a down payment should + be of type 'Service'. + Please use another product or update this product.""" + ) + ) + taxes = self.product_id.taxes_id.filtered( + lambda r: not order.company_id or r.company_id == order.company_id + ) + tax_ids = order.fiscal_position_id.map_tax(taxes, self.product_id).ids + analytic_tag_ids = [] + for line in order.sale_line_ids: + analytic_tag_ids = [ + (4, analytic_tag.id, None) + for analytic_tag in line.analytic_tag_ids + ] + + line_values = self._prepare_line( + order, analytic_tag_ids, tax_ids, amount + ) + line = sale_line_obj.sudo().create(line_values) + self._create_invoice(order, line, amount) + if self._context.get("open_invoices", False): + return folios.action_view_invoice() + return {"type": "ir.actions.act_window_close"} + + def _prepare_deposit_product(self): + return { + "name": "Down payment", + "type": "service", + "invoice_policy": "order", + "property_account_income_id": self.deposit_account_id.id, + "taxes_id": [(6, 0, self.deposit_taxes_id.ids)], + "company_id": False, + } + + @api.model + def _get_lines_to_invoice(self, folios, bill_services=True, bill_rooms=True): + lines_to_invoice = folios.sale_line_ids + if not self.bill_services: + lines_to_invoice = lines_to_invoice - lines_to_invoice.filtered( + lambda l: l.service_id and not l.service_id.is_board_service + ) + if not self.bill_rooms: + lines_to_invoice = lines_to_invoice.filtered( + lambda l: l.reservation_id and l.reservation_line_ids + ) + if not lines_to_invoice: + raise UserError(_("Nothing to invoice")) + return lines_to_invoice diff --git a/pms/wizards/folio_make_invoice_advance_views.xml b/pms/wizards/folio_make_invoice_advance_views.xml new file mode 100644 index 000000000..449d73ced --- /dev/null +++ b/pms/wizards/folio_make_invoice_advance_views.xml @@ -0,0 +1,127 @@ + + + + Invoice Orders + folio.advance.payment.inv + +
+

+ Invoices will be created in draft so that you can review + them before validation. +

+ + + + + + + + + + + +
+
+
+
+
+ + + Create invoices + ir.actions.act_window + folio.advance.payment.inv + form + new + + + list + + +
diff --git a/pms/wizards/wizard_invoice_filter_days.py b/pms/wizards/wizard_invoice_filter_days.py new file mode 100644 index 000000000..a3b91c841 --- /dev/null +++ b/pms/wizards/wizard_invoice_filter_days.py @@ -0,0 +1,114 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class InvoiceFilterDays(models.TransientModel): + + _name = "pms.invoice.filter.days" + _description = "Filter Days" + + @api.model + def default_reservation_lines(self): + return ( + self.env["account.move.line"] + .browse(self.env.context.get("active_ids")) + .reservation_line_ids + ) + + @api.model + def default_move_lines(self): + return self.env["account.move.line"].browse(self.env.context.get("active_ids")) + + @api.model + def default_from_date(self): + return min( + self.env["account.move.line"] + .browse(self.env.context.get("active_ids")) + .reservation_line_ids.mapped("date") + ) + + @api.model + def default_to_date(self): + return max( + self.env["account.move.line"] + .browse(self.env.context.get("active_ids")) + .reservation_line_ids.mapped("date") + ) + + move_line_ids = fields.Many2many("account.move.line", default=default_move_lines) + move_ids = fields.Many2many("account.move", compute="_compute_move_ids") + reservation_line_ids = fields.Many2many( + "pms.reservation.line", default=default_reservation_lines + ) + from_date = fields.Date("Date From", default=default_from_date) + to_date = fields.Date("Date to", default=default_to_date) + date_ids = fields.One2many( + comodel_name="pms.invoice.filter.days.items", + inverse_name="filter_wizard_id", + compute="_compute_date_ids", + store=True, + readonly=False, + ) + + def do_filter(self): + self.ensure_one() + invoice_lines = self.move_line_ids + for line in invoice_lines: + reservation_lines = line.reservation_line_ids.filtered( + lambda d: d.date in self.date_ids.filtered("included").mapped("date") + ) + if not reservation_lines: + raise UserError(_("You can not remove all lines for invoice")) + else: + # Write on invoice for syncr business/account + line.move_id.write( + { + "invoice_line_ids": [ + ( + 1, + line.id, + { + "reservation_line_ids": [ + (6, False, reservation_lines.ids) + ], + "quantity": len(reservation_lines), + }, + ) + ] + } + ) + + @api.depends("from_date", "to_date", "reservation_line_ids") + def _compute_date_ids(self): + self.ensure_one() + date_list = [(5, 0, 0)] + dates = self.reservation_line_ids.filtered( + lambda d: d.date >= self.from_date and d.date <= self.to_date + ).mapped("date") + for date in dates: + date_list.append( + ( + 0, + False, + { + "date": date, + }, + ) + ) + self.date_ids = date_list + + @api.depends("move_line_ids") + def _compute_move_ids(self): + self.ensure_one() + self.move_ids = [(6, 0, self.move_line_ids.mapped("move_id.id"))] + + +class InvoiceFilterDaysItems(models.TransientModel): + + _name = "pms.invoice.filter.days.items" + _description = "Item Days" + _rec_name = "date" + + date = fields.Date("Date") + included = fields.Boolean("Included", default=True) + filter_wizard_id = fields.Many2one(comodel_name="pms.invoice.filter.days") diff --git a/pms/wizards/wizard_invoice_filter_days.xml b/pms/wizards/wizard_invoice_filter_days.xml new file mode 100644 index 000000000..dc761d214 --- /dev/null +++ b/pms/wizards/wizard_invoice_filter_days.xml @@ -0,0 +1,86 @@ + + + + + pms.invoice.filter.days.form + pms.invoice.filter.days + +
+
+ + + +
+ + + + + + + + + + + +
+
+ +
+
+ + + Filter Days + pms.invoice.filter.days + form + new + +
+
diff --git a/pms/wizards/wizard_payment_folio.py b/pms/wizards/wizard_payment_folio.py new file mode 100644 index 000000000..71dd219c5 --- /dev/null +++ b/pms/wizards/wizard_payment_folio.py @@ -0,0 +1,124 @@ +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class WizardPaymentFolio(models.TransientModel): + + _name = "wizard.payment.folio" + _description = "Payments" + + @api.model + def default_folio_id(self): + return self.env["pms.folio"].browse(self._context.get("active_id", [])).id + + @api.model + def _default_amount(self): + folio = self.env["pms.folio"].browse(self._context.get("active_id", [])) + return folio.pending_amount + + @api.model + def _default_partner(self): + folio = self.env["pms.folio"].browse(self._context.get("active_id", [])) + return folio.partner_id.id + + folio_id = fields.Many2one( + "pms.folio", + string="Folio", + required=True, + default=default_folio_id, + ) + reservation_ids = fields.Many2many( + "pms.reservation", + string="Reservations", + ) + service_ids = fields.Many2many( + "pms.service", + string="Services", + ) + payment_method_id = fields.Many2one( + "account.journal", + string="Payment Method", + required=True, + domain="[('id', 'in', allowed_method_ids)]", + ) + allowed_method_ids = fields.Many2many( + "account.journal", + "allowed_payment_journal_rel", + "payment_id", + "journal_id", + compute="_compute_allowed_method_ids", + store="True", + ) + amount = fields.Float("Amount", digits=("Product Price"), default=_default_amount) + date = fields.Date("Date", default=fields.Date.context_today, required=True) + partner_id = fields.Many2one("res.partner", default=_default_partner) + + @api.depends("folio_id") + def _compute_allowed_method_ids(self): + self.ensure_one() + journal_ids = False + if self.folio_id: + journal_ids = self.folio_id.pms_property_id._get_payment_methods().ids + self.allowed_method_ids = journal_ids + + def button_payment(self): + BankStatementLine = self.env["account.bank.statement.line"] + line = self._get_statement_line_vals( + journal=self.payment_method_id, + receivable_account=self.payment_method_id.suspense_account_id, + user=self.env.user, + amount=self.amount, + folios=self.folio_id, + partner=self.partner_id, + date=self.date, + ) + BankStatementLine.sudo().create(line) + + def _get_statement_line_vals( + self, + journal, + receivable_account, + user, + amount, + folios, + reservations=False, + services=False, + partner=False, + date=False, + ): + property_folio_id = folios.mapped("pms_property_id.id") + if len(property_folio_id) != 1: + raise ValidationError(_("Only can payment by property")) + statement = ( + self.env["account.bank.statement"] + .sudo() + .search( + [ + ("journal_id", "=", journal.id), + ("property_id", "=", property_folio_id[0]), + ("state", "=", "open"), + ] + ) + ) + reservation_ids = reservations.ids if reservations else [] + service_ids = services.ids if services else [] + # TODO: If not open statement, create new, with cash control option + if statement: + return { + "date": date, + "amount": amount, + "partner_id": partner.id if partner else False, + "statement_folio_ids": [(6, 0, folios.ids)], + "reservation_ids": [(6, 0, reservation_ids)], + "service_ids": [(6, 0, service_ids)], + "payment_ref": folios.mapped("name"), + "statement_id": statement.id, + "journal_id": statement.journal_id.id, + "counterpart_account_id": receivable_account.id, + } + else: + return False diff --git a/pms/wizards/wizard_payment_folio.xml b/pms/wizards/wizard_payment_folio.xml new file mode 100644 index 000000000..be0b03f94 --- /dev/null +++ b/pms/wizards/wizard_payment_folio.xml @@ -0,0 +1,43 @@ + + + + wizard.payment.folio.view.form + wizard.payment.folio + +
+ + + + + + + + + + + + +
+
+
+
+
+ + + Payment Folio + ir.actions.act_window + wizard.payment.folio + + form + new + + +