Files
pms/pms/models/pms_service.py
Sara 57d3ab0c8a Multiproperty Constrains (#30)
* [IMP] add multiproperties demo data

* [IMP] add multiproperties checks in res_users

* [IMP] add test case in test_res_users

* [IMP] Add multiproperty checks in pms_amenity and pms_amenity_type

* [IMP] Add multiproperty in pms_board_service_room_type(pending review)

* [IMP] Add test case in test_pms_room_type_availability_rule to check multiproperties

* [IMP] Fixing test case in test_pms_room_type_availability_rule to check multiproperties

* [IMP] Add test case in test_pms_room_type_availability_rule

* [IMP] Removed field default_availability_plan_id from pms_property

* [IMP] Add multiproperty in pms_room_type_available_plan

* [IMP] pms: adding property in rooms_available

* [IMP] Add multiproperty in pms_room_type_availability_rule and product_pricelist(work in progress)

* [IMP] Add multiproperty in product_pricelist and product_pricelist_item

* [IMP] add multiproperties demo data

* [IMP] add multiproperties checks in res_users

* [IMP] add test case in test_res_users and pms_room_type_availability_rule

* [IMP] Add multiproperty checks in pms_amenity and pms_amenity_type

* [IMP] Add multiproperty in pms_board_service_room_type(pending review)

* [IMP] Removed field default_availability_plan_id from pms_property

* [IMP] Add multiproperty in pms_room_type_available_plan

* [IMP] pms: adding property in rooms_available

* [IMP] Add multiproperty in pms_room_type_availability_rule and product_pricelist(work in progress)

* [IMP] Add multiproperty in product_pricelist and product_pricelist_item

* [IMP] Pms: add compute_folio method in pms.service

* [IMP] Pms: add multiproperty integrity checks between room_type and its class

* [IMP] Pms: pms_property_id related to folio

* [IMP] Pms: add multiproperty integrity checks in pms_room with pms_room_type and pms_floor

* [IMP] Pms: adding multiproperty checks in room_type(work in progress)

* [IMP] Pms: Add property rules

* [FIX]pms: external ids security rules

* [FIX]pms: property checks

* [FIX]pms: get product on pricelist item multiproperty check

* [FIX]pms: delete test field default_plan

* [FIX]pms: property constrain to product from room type model

* [FIX]pms: ids references

* [IMP]pms: folio wizard price flow on odoo standar

Co-authored-by: Darío Lodeiros <dario@commitsun.com>
2021-01-31 13:07:03 +01:00

580 lines
23 KiB
Python

# Copyright 2017 Alexandre Díaz
# Copyright 2017 Dario Lodeiros
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.tools import float_compare, float_is_zero
_logger = logging.getLogger(__name__)
class PmsService(models.Model):
_name = "pms.service"
_description = "Services and its charges"
# Default methods
def name_get(self):
result = []
for rec in self:
name = []
name.append("{name}".format(name=rec.name))
if rec.reservation_id.name:
name.append("{name}".format(name=rec.reservation_id.name))
result.append((rec.id, ", ".join(name)))
return result
@api.model
def _default_reservation_id(self):
if self.env.context.get("reservation_ids"):
ids = [item[1] for item in self.env.context["reservation_ids"]]
return self.env["pms.reservation"].browse([(ids)], limit=1)
elif self.env.context.get("default_reservation_id"):
return self.env.context.get("default_reservation_id")
return False
# Fields declaration
name = fields.Char(
"Service description",
compute="_compute_name",
store=True,
readonly=False,
)
product_id = fields.Many2one(
"product.product", "Service", ondelete="restrict", required=True
)
folio_id = fields.Many2one(
comodel_name="pms.folio",
string="Folio",
compute="_compute_folio_id",
readonly=False,
store=True
)
reservation_id = fields.Many2one(
"pms.reservation", "Room", default=_default_reservation_id
)
service_line_ids = fields.One2many(
"pms.service.line",
"service_id",
compute="_compute_service_line_ids",
store=True,
readonly=False,
)
company_id = fields.Many2one(
related="folio_id.company_id", string="Company", store=True, readonly=True
)
pms_property_id = fields.Many2one(
comodel_name="pms.property", store=True, readonly=True, related="folio_id.pms_property_id"
)
tax_ids = fields.Many2many(
"account.tax",
string="Taxes",
compute="_compute_tax_ids",
store=True,
readonly=False,
domain=["|", ("active", "=", False), ("active", "=", True)],
)
move_line_ids = fields.Many2many(
"account.move.line",
"service_line_move_rel",
"service_id",
"move_line_id",
string="move Lines",
copy=False,
)
analytic_tag_ids = fields.Many2many("account.analytic.tag", string="Analytic Tags")
currency_id = fields.Many2one(
related="folio_id.currency_id", store=True, string="Currency", readonly=True
)
sequence = fields.Integer(string="Sequence", default=10)
state = fields.Selection(related="folio_id.state")
per_day = fields.Boolean(related="product_id.per_day", related_sudo=True)
product_qty = fields.Integer(
"Quantity",
compute="_compute_product_qty",
store=True,
readonly=False,
)
is_board_service = fields.Boolean()
# Non-stored related field to allow portal user to
# see the image of the product he has ordered
product_image = fields.Binary(
"Product Image", related="product_id.image_1024", store=False, related_sudo=True
)
invoice_status = fields.Selection(
[
("invoiced", "Fully Invoiced"),
("to invoice", "To Invoice"),
("no", "Nothing to Invoice"),
],
string="Invoice Status",
compute="_compute_invoice_status",
store=True,
readonly=True,
default="no",
)
channel_type = fields.Selection(
[
("door", "Door"),
("mail", "Mail"),
("phone", "Phone"),
("call", "Call Center"),
("web", "Web"),
],
string="Sales Channel",
)
price_unit = fields.Float(
"Unit Price",
digits=("Product Price"),
compute="_compute_price_unit",
store=True,
readonly=False,
)
discount = fields.Float(string="Discount (%)", digits=("Discount"), default=0.0)
qty_to_invoice = fields.Float(
compute="_compute_get_to_invoice_qty",
string="To Invoice",
store=True,
readonly=True,
digits=("Product Unit of Measure"),
)
qty_invoiced = fields.Float(
compute="_compute_get_invoice_qty",
string="Invoiced",
store=True,
readonly=True,
digits=("Product Unit of Measure"),
)
price_subtotal = fields.Monetary(
string="Subtotal", readonly=True, store=True, compute="_compute_amount_service"
)
price_total = fields.Monetary(
string="Total", readonly=True, store=True, compute="_compute_amount_service"
)
price_tax = fields.Float(
string="Taxes Amount",
readonly=True,
store=True,
compute="_compute_amount_service",
)
# Compute and Search methods
@api.depends("product_id")
def _compute_name(self):
self.name = False
for service in self.filtered("product_id"):
product = service.product_id.with_context(
lang=service.folio_id.partner_id.lang,
partner=service.folio_id.partner_id.id,
)
title = False
message = False
warning = {}
if product.sale_line_warn != "no-message":
title = _("Warning for %s") % product.name
message = product.sale_line_warn_msg
warning["title"] = title
warning["message"] = message
result = {"warning": warning}
if product.sale_line_warn == "block":
self.product_id = False
return result
name = product.name_get()[0][1]
if product.description_sale:
name += "\n" + product.description_sale
service.name = name
@api.depends("reservation_id.checkin", "reservation_id.checkout", "product_id")
def _compute_service_line_ids(self):
for service in self:
if service.product_id:
day_qty = 1
if service.reservation_id and service.product_id:
reservation = service.reservation_id
product = service.product_id
consumed_on = product.consumed_on
if product.per_day:
lines = []
day_qty = service._service_day_qty()
days_diff = (reservation.checkout - reservation.checkin).days
for i in range(0, days_diff):
if consumed_on == "after":
i += 1
idate = reservation.checkin + timedelta(days=i)
old_line = service._search_old_lines(idate)
if idate in [
line.date for line in service.service_line_ids
]:
# REVIEW: If the date is already
# cached (otherwise double the date)
pass
elif not old_line:
lines.append(
(
0,
False,
{
"date": idate,
"day_qty": day_qty,
},
)
)
else:
lines.append((4, old_line.id))
move_day = 0
if consumed_on == "after":
move_day = 1
service.service_line_ids -= (
service.service_line_ids.filtered_domain(
[
"|",
(
"date",
"<",
reservation.checkin + timedelta(move_day),
),
(
"date",
">=",
reservation.checkout + timedelta(move_day),
),
]
)
)
_logger.info(service)
_logger.info(lines)
service.service_line_ids = lines
else:
# TODO: Review (business logic refact) no per_day logic service
if not service.service_line_ids:
service.service_line_ids = [
(
0,
False,
{
"date": fields.Date.today(),
"day_qty": day_qty,
},
)
]
else:
# TODO: Service without reservation(room) but with folio¿?
# example: tourist tour in group
if not service.service_line_ids:
service.service_line_ids = [
(
0,
False,
{
"date": fields.Date.today(),
"day_qty": day_qty,
},
)
]
else:
service.service_line_ids = False
def _search_old_lines(self, date):
self.ensure_one()
if isinstance(self._origin.id, int):
old_line = self._origin.service_line_ids.filtered(lambda r: r.date == date)
return old_line
return False
@api.depends("product_id")
def _compute_tax_ids(self):
for service in self:
service.tax_ids = service.product_id.taxes_id.filtered(
lambda r: not service.company_id or r.company_id == service.company_id
)
@api.depends("service_line_ids", "service_line_ids.day_qty")
def _compute_product_qty(self):
self.product_qty = 0
for service in self.filtered("service_line_ids"):
qty = sum(service.service_line_ids.mapped("day_qty"))
service.product_qty = qty
@api.depends(
"product_id",
"service_line_ids",
"reservation_id.pricelist_id",
"reservation_id.pms_property_id",
"pms_property_id",
)
def _compute_price_unit(self):
for service in self:
folio = service.folio_id
reservation = service.reservation_id
origin = reservation if reservation else folio
if origin:
if service._recompute_price():
partner = origin.partner_id
pricelist = origin.pricelist_id
if reservation and service.is_board_service:
board_room_type = reservation.board_service_room_id
if board_room_type.price_type == "fixed":
service.price_unit = (
self.env["pms.board.service.room.type.line"]
.search(
[
(
"pms_board_service_room_type_id",
"=",
board_room_type.id,
),
("product_id", "=", service.product_id.id),
]
)
.amount
)
else:
service.price_unit = (
reservation.price_total
* self.env["pms.board.service.room.type.line"]
.search(
[
(
"pms_board_service_room_type_id",
"=",
board_room_type.id,
),
("product_id", "=", service.product_id.id),
]
)
.amount
) / 100
else:
product = service.product_id.with_context(
lang=partner.lang,
partner=partner.id,
quantity=service.product_qty,
date=folio.date_order if folio else fields.Date.today(),
pricelist=pricelist.id,
uom=service.product_id.uom_id.id,
fiscal_position=False,
property=service.pms_property_id.id,
)
service.price_unit = self.env[
"account.tax"
]._fix_tax_included_price_company(
service._get_display_price(product),
product.taxes_id,
service.tax_ids,
origin.company_id,
)
else:
service.price_unit = service._origin.price_unit
else:
service.price_unit = 0
@api.depends(
"reservation_id"
)
def _compute_folio_id(self):
for record in self:
if record.reservation_id:
record.folio_id = record.reservation_id.folio_id
elif not record.folio_id:
record.folio_id = False
def _recompute_price(self):
# REVIEW: Conditional to avoid overriding already calculated prices,
# I'm not sure it's the best way
self.ensure_one()
# folio/reservation origin service
folio_origin = self._origin.folio_id
reservation_origin = self._origin.reservation_id
origin = reservation_origin if reservation_origin else folio_origin
# folio/reservation new service
folio_new = self.folio_id
reservation_new = self.reservation_id
new = reservation_new if reservation_new else folio_new
price_fields = [
"pricelist_id",
"reservation_type",
"pms_property_id",
]
if (
any(origin[field] != new[field] for field in price_fields)
or self._origin.price_unit == 0
):
return True
return False
@api.depends("qty_invoiced", "product_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_qty - line.qty_invoiced
else:
line.qty_to_invoice = 0
@api.depends("move_line_ids.move_id.state", "move_line_ids.quantity")
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.move_line_ids:
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_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")
def _compute_invoice_status(self):
"""
Compute the invoice status of a SO line. Possible statuses:
- no: if the SO is not in status 'sale' or 'done',
we consider that there is nothing to invoice.
This is also hte default value if the conditions of no other
status is met.
- to invoice: we refer to the quantity to invoice of the line.
Refer to method `_compute_get_to_invoice_qty()` for more information on
how this quantity is calculated.
- upselling: this is possible only for a product invoiced on ordered
quantities for which we delivered more than expected.
The could arise if, for example, a project took more time than
expected but we decided not to invoice the extra cost to the
client. This occurs onyl in state 'sale', so that when a Folio
is set to done, the upselling opportunity is removed from the list.
- invoiced: the quantity invoiced is larger or equal to the
quantity ordered.
"""
precision = self.env["decimal.precision"].precision_get(
"Product Unit of Measure"
)
for line in self:
state = line.folio_id.state or "draft"
if 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_qty, precision_digits=precision
)
>= 0
):
line.invoice_status = "invoiced"
else:
line.invoice_status = "no"
@api.depends("product_qty", "discount", "price_unit", "tax_ids")
def _compute_amount_service(self):
for service in self:
folio = service.folio_id
reservation = service.reservation_id
currency = folio.currency_id if folio else reservation.currency_id
product = service.product_id
price = service.price_unit * (1 - (service.discount or 0.0) * 0.01)
taxes = service.tax_ids.compute_all(
price, currency, service.product_qty, product=product
)
service.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"],
}
)
# Action methods
def open_service_ids(self):
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"
return action
# ORM Overrides
@api.model
def name_search(self, name="", args=None, operator="ilike", limit=100):
if args is None:
args = []
if not (name == "" and operator == "ilike"):
args += [
"|",
("reservation_id.name", operator, name),
("name", operator, name),
]
return super(PmsService, self).name_search(
name="", args=args, operator="ilike", limit=limit
)
def _get_display_price(self, product):
folio = self.folio_id
reservation = self.reservation_id
origin = folio if folio else reservation
if origin.pricelist_id.discount_policy == "with_discount":
return product.with_context(pricelist=origin.pricelist_id.id).price
product_context = dict(
self.env.context,
partner_id=origin.partner_id.id,
date=folio.date_order if folio else fields.Date.today(),
uom=self.product_id.uom_id.id,
)
final_price, rule_id = origin.pricelist_id.with_context(
product_context
).get_product_price_rule(
self.product_id, self.product_qty or 1.0, origin.partner_id
)
base_price, currency_id = self.with_context(
product_context
)._get_real_price_currency(
product,
rule_id,
self.product_qty,
self.product_id.uom_id,
origin.pricelist_id.id,
)
if currency_id != origin.pricelist_id.currency_id.id:
base_price = (
self.env["res.currency"]
.browse(currency_id)
.with_context(product_context)
.compute(base_price, origin.pricelist_id.currency_id)
)
# negative discounts (= surcharge) are included in the display price
return max(base_price, final_price)
# Businness Methods
def _service_day_qty(self):
self.ensure_one()
qty = self.product_qty if len(self.service_line_ids) == 1 else 0
if not self.reservation_id:
return qty
# TODO: Pass per_person to service line from product default_per_person
if self.product_id.per_person:
qty = self.reservation_id.adults
return qty