diff --git a/hotel/models/__init__.py b/hotel/models/__init__.py index c68f79c5b..46cfdf7ff 100644 --- a/hotel/models/__init__.py +++ b/hotel/models/__init__.py @@ -31,3 +31,4 @@ from . import hotel_service_line from . import hotel_board_service from . import hotel_board_service_room_type_line from . import hotel_board_service_line +from . import inherited_account_invoice_line diff --git a/hotel/models/hotel_folio.py b/hotel/models/hotel_folio.py index 656aab0bb..5049dd8e4 100644 --- a/hotel/models/hotel_folio.py +++ b/hotel/models/hotel_folio.py @@ -11,6 +11,8 @@ from dateutil.relativedelta import relativedelta from odoo.exceptions import except_orm, UserError, ValidationError from odoo.tools import ( misc, + float_is_zero, + float_compare, DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT) from odoo import models, fields, api, _ @@ -31,9 +33,57 @@ class HotelFolio(models.Model): # @api.depends('product_id.invoice_policy', 'order_id.state') def _compute_qty_delivered_updateable(self): pass - # @api.depends('state', 'order_line.invoice_status') + + @api.depends('state', 'room_lines.invoice_status', 'service_ids.invoice_status') def _get_invoiced(self): - pass + """ + 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. + - upselling: if all Folio lines are invoiced or upselling, the status is upselling. + + 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. + """ + for folio in self: + invoice_ids = folio.room_lines.mapped('invoice_line_ids').mapped('invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund']) + invoice_ids |= folio.service_ids.mapped('invoice_line_ids').mapped('invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund']) + # Search for invoices which have been 'cancelled' (filter_refund = 'modify' in + # 'account.invoice.refund') + # use like as origin may contains multiple references (e.g. 'SO01, SO02') + refunds = invoice_ids.search([('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.invoice'].browse() + if invoice_ids: + for inv in invoice_ids: + refund_ids += refund_ids.search([('type', '=', 'out_refund'), ('origin', '=', inv.number), ('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() + #~ line_invoice_status = [line.invoice_status for line in order.order_line if line.product_id != deposit_product_id] + + #~ TODO: REVIEW INVOICE_STATUS + #~ if folio.state not in ('confirm', 'done'): + #~ invoice_status = 'no' + #~ elif any(invoice_status == 'to invoice' for invoice_status in line_invoice_status): + #~ invoice_status = 'to invoice' + #~ elif all(invoice_status == 'invoiced' for invoice_status in line_invoice_status): + #~ invoice_status = 'invoiced' + #~ elif all(invoice_status in ['invoiced', 'upselling'] for invoice_status in line_invoice_status): + #~ invoice_status = 'upselling' + #~ else: + #~ invoice_status = 'no' + + folio.update({ + 'invoice_count': len(set(invoice_ids.ids + refund_ids.ids)), + 'invoice_ids': invoice_ids.ids + refund_ids.ids, + #~ 'invoice_status': invoice_status + }) + # @api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced') def _compute_invoice_status(self): pass @@ -44,9 +94,14 @@ class HotelFolio(models.Model): def _amount_all(self): pass + @api.model + def _get_default_team(self): + return self.env['crm.team']._get_default_team_id() + #Main Fields-------------------------------------------------------- name = fields.Char('Folio Number', readonly=True, index=True, default=lambda self: _('New')) + client_order_ref = fields.Char(string='Customer Reference', copy=False) partner_id = fields.Many2one('res.partner', track_visibility='onchange') @@ -61,8 +116,8 @@ class HotelFolio(models.Model): help="Hotel services detail provide to " "customer and it will include in " "main Invoice.") - company_id = fields.Many2one('res.company', 'Company') - + company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('hotel.folio')) + analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a folio.", copy=False) currency_id = fields.Many2one('res.currency', related='pricelist_id.currency_id', string='Currency', readonly=True, required=True) @@ -87,6 +142,7 @@ class HotelFolio(models.Model): required=True, readonly=True, index=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=False, default=fields.Datetime.now) + confirmation_date = fields.Datetime(string='Confirmation Date', readonly=True, index=True, help="Date on which the folio is confirmed.", copy=False) state = fields.Selection([ ('draft', 'Quotation'), ('sent', 'Quotation Sent'), @@ -111,6 +167,7 @@ class HotelFolio(models.Model): readonly=True) return_ids = fields.One2many('payment.return', 'folio_id', readonly=True) + payment_term_id = fields.Many2one('account.payment.term', string='Payment Terms', oldname='payment_term') #Amount Fields------------------------------------------------------ pending_amount = fields.Monetary(compute='compute_amount', @@ -139,8 +196,7 @@ class HotelFolio(models.Model): compute='_compute_checkin_partner_count') #Invoice Fields----------------------------------------------------- - hotel_invoice_id = fields.Many2one('account.invoice', 'Invoice') - num_invoices = fields.Integer(compute='_compute_num_invoices') + invoice_count = fields.Integer(compute='_get_invoiced') invoice_ids = fields.Many2many('account.invoice', string='Invoices', compute='_get_invoiced', readonly=True, copy=False) invoice_status = fields.Selection([('upselling', 'Upselling Opportunity'), @@ -150,12 +206,11 @@ class HotelFolio(models.Model): string='Invoice Status', compute='_compute_invoice_status', store=True, readonly=True, default='no') - #~ partner_invoice_id = fields.Many2one('res.partner', - #~ string='Invoice Address', - #~ readonly=True, required=True, - #~ states={'draft': [('readonly', False)], - #~ 'sent': [('readonly', False)]}, - #~ help="Invoice address for current sales order.") + partner_invoice_id = fields.Many2one('res.partner', + string='Invoice Address', required=True, + states={'done': [('readonly', True)]}, + help="Invoice address for current sales order.") + fiscal_position_id = fields.Many2one('account.fiscal.position', oldname='fiscal_position', string='Fiscal Position') #WorkFlow Mail Fields----------------------------------------------- has_confirmed_reservations_to_send = fields.Boolean( @@ -173,12 +228,12 @@ class HotelFolio(models.Model): 'Prepaid Warning Days', help='Margin in days to create a notice if a payment \ advance has not been recorded') - rooms_char = fields.Char('Rooms', compute='_computed_rooms_char') segmentation_ids = fields.Many2many('res.partner.category', string='Segmentation') client_order_ref = fields.Char(string='Customer Reference', copy=False) note = fields.Text('Terms and conditions') sequence = fields.Integer(string='Sequence', default=10) + team_id = fields.Many2one('crm.team', 'Sales Channel', change_default=True, default=_get_default_team, oldname='section_id') @api.depends('room_lines.price_total','service_ids.price_total') def _amount_all(self): @@ -197,20 +252,32 @@ class HotelFolio(models.Model): 'amount_total': amount_untaxed + amount_tax, }) - def _computed_rooms_char(self): - for record in self: - record.rooms_char = ', '.join(record.mapped('room_lines.room_id.name')) - - @api.multi - def _compute_num_invoices(self): - pass - # for fol in self: - # fol.num_invoices = len(self.mapped('invoice_ids.id')) - - # @api.depends('order_line.price_total', 'payment_ids', 'return_ids') + @api.depends('amount_total', 'payment_ids', 'return_ids') @api.multi def compute_amount(self): - _logger.info('compute_amount') + 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) + vals = { + 'pending_amount': record.amount_total - total_paid + total_inv_refund, + 'invoices_paid': total_paid, + 'refund_amount': total_inv_refund, + } + record.update(vals) @api.multi def action_pay(self): @@ -356,7 +423,7 @@ class HotelFolio(models.Model): if any(f not in vals for f in lfields): partner = self.env['res.partner'].browse(vals.get('partner_id')) addr = partner.address_get(['delivery', 'invoice']) - #~ vals['partner_invoice_id'] = vals.setdefault('partner_invoice_id', addr['invoice']) + vals['partner_invoice_id'] = vals.setdefault('partner_invoice_id', addr['invoice']) vals['pricelist_id'] = vals.setdefault( 'pricelist_id', partner.property_product_pricelist and partner.property_product_pricelist.id) @@ -369,23 +436,29 @@ class HotelFolio(models.Model): """ Update the following fields when the partner is changed: - Pricelist + - Payment terms - Invoice address - - user_id + - Delivery address """ if not self.partner_id: - #~ self.update({ - #~ 'partner_invoice_id': False, - #~ 'payment_term_id': False, - #~ 'fiscal_position_id': False, - #~ }) + self.update({ + 'partner_invoice_id': False, + 'payment_term_id': False, + 'fiscal_position_id': False, + }) return + addr = self.partner_id.address_get(['invoice']) pricelist = self.partner_id.property_product_pricelist and \ self.partner_id.property_product_pricelist.id or \ self.env['ir.default'].sudo().get('res.config.settings', 'default_pricelist_id') - values = {'user_id': self.partner_id.user_id.id or self.env.uid, - 'pricelist_id': pricelist - } + values = { + 'pricelist_id': pricelist, + 'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False, + 'partner_invoice_id': addr['invoice'], + 'user_id': self.partner_id.user_id.id or self.env.uid + } + if self.env['ir.config_parameter'].sudo().get_param('sale.use_sale_note') and \ self.env.user.company_id.sale_note: values['note'] = self.with_context( @@ -413,31 +486,6 @@ class HotelFolio(models.Model): else: return 'normal' - @api.multi - def action_invoice_create(self, grouped=False, states=None): - ''' - @param self: object pointer - ''' - pass - # if states is None: - # states = ['confirmed', 'done'] - # order_ids = [folio.order_id.id for folio in self] - # sale_obj = self.env['sale.order'].browse(order_ids) - # invoice_id = (sale_obj.action_invoice_create(grouped=False, - # states=['confirmed', - # 'done'])) - # for line in self: - # values = {'invoiced': True, - # 'state': 'progress' if grouped else 'progress', - # 'hotel_invoice_id': invoice_id - # } - # line.write(values) - # return invoice_id - - @api.multi - def advance_invoice(self): - pass - ''' WORKFLOW STATE ''' @@ -483,7 +531,21 @@ class HotelFolio(models.Model): @api.multi def action_confirm(self): - _logger.info('action_confirm') + for folio in self.filtered(lambda folio: folio.partner_id not in folio.message_partner_ids): + folio.message_subscribe([folio.partner_id.id]) + self.write({ + 'state': 'confirm', + 'confirmation_date': fields.Datetime.now() + }) + #~ if self.env.context.get('send_email'): + #~ self.force_quotation_send() + + # 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')]): + #~ if not self.analytic_account_id: + #~ self._create_analytic_account() + + return True """ diff --git a/hotel/models/hotel_reservation.py b/hotel/models/hotel_reservation.py index 2a43ded84..1863c09e7 100644 --- a/hotel/models/hotel_reservation.py +++ b/hotel/models/hotel_reservation.py @@ -1,17 +1,19 @@ # Copyright 2017-2018 Alexandre Díaz # Copyright 2017 Dario Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import logging import time from datetime import timedelta from lxml import etree from odoo.exceptions import UserError, ValidationError from odoo.tools import ( misc, + float_is_zero, + float_compare, DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT) from odoo import models, fields, api, _ from odoo.addons import decimal_precision as dp +import logging _logger = logging.getLogger(__name__) @@ -76,6 +78,28 @@ class HotelReservation(models.Model): else: return default_departure_hour + @api.depends('state', 'qty_to_invoice', 'qty_invoiced') + def _compute_invoice_status(self): + """ + Compute the invoice status of a Reservation. Possible statuses: + - no: if the Folio 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 + `_get_to_invoice_qty()` for more information on how this quantity is calculated. + - 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: + if line.state in ('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, len(line.reservation_line_ids), precision_digits=precision) >= 0: + line.invoice_status = 'invoiced' + else: + line.invoice_status = 'no' + + @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): if args is None: @@ -115,6 +139,7 @@ class HotelReservation(models.Model): ).days name = fields.Text('Reservation Description', required=True) + sequence = fields.Integer(string='Sequence', default=10) room_id = fields.Many2one('hotel.room', string='Room') @@ -134,6 +159,7 @@ class HotelReservation(models.Model): track_visibility='onchange') reservation_type = fields.Selection(related='folio_id.reservation_type', default=lambda *a: 'normal') + invoice_count = fields.Integer(related='folio_id.invoice_count') board_service_room_id = fields.Many2one('hotel.board.service.room.type', string='Board Service') cancelled_reason = fields.Selection([ @@ -166,7 +192,7 @@ class HotelReservation(models.Model): partner_id = fields.Many2one(related='folio_id.partner_id') closure_reason_id = fields.Many2one(related='folio_id.closure_reason_id') - company_id = fields.Many2one('res.company', 'Company') + company_id = fields.Many2one(related='folio_id.company_id', string='Company', store=True, readonly=True) reservation_line_ids = fields.One2many('hotel.reservation.line', 'reservation_id', readonly=True, required=True, @@ -230,26 +256,24 @@ class HotelReservation(models.Model): # order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True) # product_id = fields.Many2one('product.product', related='order_line.product_id', string='Product') # product_uom = fields.Many2one('product.uom', string='Unit of Measure', required=True) - # product_uom_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0) - currency_id = fields.Many2one('res.currency', related='pricelist_id.currency_id', string='Currency', readonly=True, required=True) - # 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') - tax_id = fields.Many2many('account.tax', + 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') + tax_ids = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) - # qty_to_invoice = fields.Float( - # string='To Invoice', store=True, readonly=True, - # digits=dp.get_precision('Product Unit of Measure')) - # qty_invoiced = fields.Float( - # compute='_get_invoice_qty', string='Invoiced', store=True, readonly=True, - # digits=dp.get_precision('Product Unit of Measure')) + qty_to_invoice = fields.Float( + compute='_get_to_invoice_qty', string='To Invoice', store=True, readonly=True, + digits=dp.get_precision('Product Unit of Measure')) + qty_invoiced = fields.Float( + compute='_get_invoice_qty', string='Invoiced', store=True, readonly=True, + digits=dp.get_precision('Product Unit of Measure')) + invoice_line_ids = fields.Many2many('account.invoice.line', 'reservation_invoice_rel', 'reservation_id', 'invoice_line_id', string='Invoice Lines', copy=False) # qty_delivered = fields.Float(string='Delivered', copy=False, digits=dp.get_precision('Product Unit of Measure'), default=0.0) # qty_delivered_updateable = fields.Boolean(compute='_compute_qty_delivered_updateable', string='Can Edit Delivered', readonly=True, default=True) price_subtotal = fields.Monetary(string='Subtotal', @@ -275,7 +299,7 @@ class HotelReservation(models.Model): # FIXME discount per night discount = fields.Float(string='Discount (%)', digits=dp.get_precision('Discount'), default=0.0) - # analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') + analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') @api.model def create(self, vals): @@ -293,19 +317,12 @@ class HotelReservation(models.Model): vals.update({'folio_id': folio.id, 'reservation_type': vals.get('reservation_type'), 'channel_type': vals.get('channel_type')}) + if 'service_ids' in vals and vals['service_ids'][0][2]: + for service in vals['service_ids']: + service[2]['folio_id'] = folio.id vals.update({ 'last_updated_res': fields.Datetime.now(), }) - if 'board_service_room_id' in vals: - board_services = [] - board = self.env['hotel.board.service.room.type'].browse(vals['board_service_room_id']) - for line in board.board_service_line_ids: - board_services.append((0, False, { - 'product_id': line.product_id.id, - 'is_board_service': True, - 'folio_id': vals.get('folio_id'), - })) - vals.update({'service_ids': board_services}) if self.compute_price_out_vals(vals): days_diff = ( fields.Date.from_string(vals['checkout']) - fields.Date.from_string(vals['checkin']) @@ -349,18 +366,15 @@ class HotelReservation(models.Model): board_services = [] board = self.env['hotel.board.service.room.type'].browse(vals['board_service_room_id']) for line in board.board_service_line_ids: - board_services.append((0, False, { + res = { 'product_id': line.product_id.id, 'is_board_service': True, - 'folio_id': record.folio_id.id or vals.get('folio_id') - })) + 'folio_id': vals.get('folio_id'), + } + res.update(self.env['hotel.service']._prepare_add_missing_fields(res)) + board_services.append((0, False, vals)) # NEED REVIEW: Why I need add manually the old IDs if board service is (0,0,(-)) ¿?¿?¿ record.update({'service_ids': [(6, 0, record.service_ids.ids)] + board_services}) - update_services = record.service_ids.filtered( - lambda r: r.is_board_service == True - ) - for service in update_services: - service.onchange_product_calc_qty() if record.compute_price_out_vals(vals): record.update(record.prepare_reservation_lines( checkin, @@ -419,12 +433,12 @@ class HotelReservation(models.Model): """ Deduce missing required fields from the onchange """ res = {} onchange_fields = ['room_id', 'reservation_type', - 'currency_id', 'name', 'board_service_room_id'] + 'currency_id', 'name', 'board_service_room_id','service_ids'] if values.get('room_type_id'): line = self.new(values) if any(f not in values for f in onchange_fields): line.onchange_room_id() - line.onchange_compute_reservation_description() + line.onchange_room_type_id() line.onchange_board_service() if 'pricelist_id' not in values: line.onchange_partner_id() @@ -592,7 +606,10 @@ class HotelReservation(models.Model): update_old_prices=False)) @api.onchange('checkin', 'checkout', 'room_type_id') - def onchange_compute_reservation_description(self): + def onchange_room_type_id(self): + """ + When change de room_type_id, we calc the line description and tax_ids + """ if self.room_type_id and self.checkin and self.checkout: checkin_dt = fields.Date.from_string(self.checkin) checkout_dt = fields.Date.from_string(self.checkout) @@ -600,12 +617,13 @@ class HotelReservation(models.Model): checkout_str = checkout_dt.strftime('%d/%m/%Y') self.name = self.room_type_id.name + ': ' + checkin_str + ' - '\ + checkout_str + self._compute_tax_ids() @api.onchange('checkin', 'checkout') def onchange_update_service_per_day(self): services = self.service_ids.filtered(lambda r: r.per_day == True) for service in services: - service.onchange_product_calc_qty() + service.onchange_product_id() @api.multi @api.onchange('checkin', 'checkout', 'room_id') @@ -637,20 +655,23 @@ class HotelReservation(models.Model): for line in self.board_service_room_id.board_service_line_ids: product = line.product_id if product.per_day: - vals = { + res = { 'product_id': product.id, 'is_board_service': True, 'folio_id': self.folio_id.id, } - vals.update(self.env['hotel.service'].prepare_service_lines( + line = self.env['hotel.service'].new(res) + res.update(self.env['hotel.service']._prepare_add_missing_fields(res)) + res.update(self.env['hotel.service'].prepare_service_lines( dfrom=self.checkin, days=self.nights, per_person=product.per_person, persons=self.adults, old_line_days=False)) - board_services.append((0, False, vals)) + board_services.append((0, False, res)) other_services = self.service_ids.filtered(lambda r: r.is_board_service == False) - self.update({'service_ids': [(6, 0, other_services.ids)] + board_services}) + self.update({'service_ids': board_services}) + self.service_ids |= other_services for service in self.service_ids.filtered(lambda r: r.is_board_service == True): service._compute_tax_ids() service.price_unit = service._compute_price_unit() @@ -762,7 +783,7 @@ class HotelReservation(models.Model): return True return False - @api.depends('reservation_line_ids', 'reservation_line_ids.discount', 'tax_id') + @api.depends('reservation_line_ids', 'reservation_line_ids.discount', 'tax_ids') def _compute_amount_reservation(self): """ Compute the amounts of the reservation. @@ -772,7 +793,7 @@ class HotelReservation(models.Model): if amount_room > 0: product = record.room_type_id.product_id price = amount_room * (1 - (record.discount or 0.0) * 0.01) - taxes = record.tax_id.compute_all(price, record.currency_id, 1, product=product) + taxes = record.tax_ids.compute_all(price, record.currency_id, 1, product=product) record.update({ 'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])), 'price_total': taxes['total_included'], @@ -806,7 +827,7 @@ class HotelReservation(models.Model): pricelist=pricelist_id, uom=product.uom_id.id) line_price = self.env['account.tax']._fix_tax_included_price_company( - product.price, product.taxes_id, self.tax_id, self.company_id) + product.price, product.taxes_id, self.tax_ids, self.company_id) if old_line: cmds.append((1, old_line.id, { 'price': line_price @@ -1012,7 +1033,7 @@ class HotelReservation(models.Model): 'ignore_avail_restrictions': True}).create(vals) if not reservation_copy: raise ValidationError(_("Unexpected error copying record. \ - Can't split reservation!")) + Can't split reservation!")) record.write({ 'checkout': new_start_date_dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT), 'price_total': tprice[0], @@ -1124,3 +1145,56 @@ class HotelReservation(models.Model): @api.multi def send_cancel_mail(self): return self.folio_id.send_cancel_mail() + + """ + INVOICING PROCESS + """ + + @api.multi + def open_invoices_reservation(self): + invoices = self.folio_id.mapped('invoice_ids') + action = self.env.ref('account.action_invoice_tree1').read()[0] + if len(invoices) > 1: + action['domain'] = [('id', 'in', invoices.ids)] + elif len(invoices) == 1: + action['views'] = [(self.env.ref('account.invoice_form').id, 'form')] + action['res_id'] = invoices.ids[0] + else: + action = self.env.ref('hotel.action_view_folio_advance_payment_inv').read()[0] + action['context'] = {'default_reservation_id': self.id, + 'default_folio_id': self.folio_id.id} + return action + + @api.multi + 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') + 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) + + @api.depends('qty_invoiced', 'nights', 'folio_id.state') + def _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 = len(line.reservation_line_ids) - line.qty_invoiced + else: + line.qty_to_invoice = 0 + + @api.depends('invoice_line_ids.invoice_id.state', 'invoice_line_ids.quantity') + def _get_invoice_qty(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: + qty_invoiced = 0.0 + for day in line.reservation_line_ids: + invoice_lines = day.invoice_line_ids.filtered(lambda r: r.invoice_id.state != 'cancel') + qty_invoiced += len(invoice_lines.filtered(lambda r: r.invoice_id.type == 'out_invoice')) - \ + len(invoice_lines.filtered(lambda r: r.invoice_id.type == 'out_refund')) + line.qty_invoiced = qty_invoiced diff --git a/hotel/models/hotel_reservation_line.py b/hotel/models/hotel_reservation_line.py index bc87355af..7ee0fbf22 100644 --- a/hotel/models/hotel_reservation_line.py +++ b/hotel/models/hotel_reservation_line.py @@ -4,11 +4,21 @@ from odoo import models, fields, api, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import ValidationError +from datetime import date class HotelReservationLine(models.Model): _name = "hotel.reservation.line" _order = "date" + @api.multi + def name_get(self): + result = [] + for res in self: + date = fields.Date.from_string(res.date) + name = u'%s/%s' % (date.day, date.month) + result.append((res.id, name)) + return result + reservation_id = fields.Many2one('hotel.reservation', string='Reservation', ondelete='cascade', required=True, copy=False) @@ -17,6 +27,11 @@ class HotelReservationLine(models.Model): discount = fields.Float( string='Discount (%)', digits=dp.get_precision('Discount'), default=0.0) + invoice_line_ids = fields.Many2many( + 'account.invoice.line', + 'reservation_line_invoice_rel', + 'reservation_line_id', 'invoice_line_id', + string='Invoice Lines', readonly=True, copy=False) @api.constrains('date') def constrains_duplicated_date(self): diff --git a/hotel/models/hotel_service.py b/hotel/models/hotel_service.py index eac017044..e472f3bad 100644 --- a/hotel/models/hotel_service.py +++ b/hotel/models/hotel_service.py @@ -2,7 +2,10 @@ # Copyright 2017 Dario Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import models, fields, api, _ -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo.tools import ( + float_is_zero, + float_compare, + DEFAULT_SERVER_DATE_FORMAT) from datetime import timedelta from odoo.exceptions import ValidationError from odoo.addons import decimal_precision as dp @@ -42,11 +45,78 @@ class HotelService(models.Model): ids = [item[1] for item in self.env.context['room_lines']] return self.env['hotel.reservation'].browse([ (ids)], limit=1) + elif self.env.context.get('default_ser_room_line'): + return self.env.context.get('default_ser_room_line') return False - name = fields.Char('Service description') + @api.model + def _default_folio_id(self): + if 'folio_id' in self._context: + return self._context['folio_id'] + return False + + @api.depends('qty_invoiced', 'product_qty', 'folio_id.state') + def _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('invoice_line_ids.invoice_id.state', 'invoice_line_ids.quantity') + def _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 SO 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 SO + """ + for line in self: + qty_invoiced = 0.0 + for invoice_line in line.invoice_line_ids: + if invoice_line.invoice_id.state != 'cancel': + if invoice_line.invoice_id.type == 'out_invoice': + qty_invoiced += invoice_line.uom_id._compute_quantity(invoice_line.quantity, line.product_id.uom_id) + elif invoice_line.invoice_id.type == 'out_refund': + qty_invoiced -= invoice_line.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 + `_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 SO 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: + if line.folio_id.state in ('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' + + name = fields.Char('Service description', required=True) + sequence = fields.Integer(string='Sequence', default=10) product_id = fields.Many2one('product.product', 'Service', required=True) - folio_id = fields.Many2one('hotel.folio', 'Folio', ondelete='cascade') + folio_id = fields.Many2one('hotel.folio', 'Folio', + ondelete='cascade', + default=_default_folio_id) ser_room_line = fields.Many2one('hotel.reservation', 'Room', default=_default_ser_room_line) per_day = fields.Boolean(related='product_id.per_day') @@ -57,6 +127,11 @@ class HotelService(models.Model): # 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", store=False) company_id = fields.Many2one(related='folio_id.company_id', string='Company', store=True, readonly=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'), @@ -67,6 +142,14 @@ class HotelService(models.Model): tax_ids = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) discount = fields.Float(string='Discount (%)', digits=dp.get_precision('Discount'), default=0.0) currency_id = fields.Many2one(related='folio_id.currency_id', store=True, string='Currency', readonly=True) + invoice_line_ids = fields.Many2many('account.invoice.line', 'service_line_invoice_rel', 'service_id', 'invoice_line_id', string='Invoice Lines', copy=False) + analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') + qty_to_invoice = fields.Float( + compute='_get_to_invoice_qty', string='To Invoice', store=True, readonly=True, + digits=dp.get_precision('Product Unit of Measure')) + qty_invoiced = fields.Float( + compute='_get_invoice_qty', string='Invoiced', store=True, readonly=True, + digits=dp.get_precision('Product Unit of Measure')) price_subtotal = fields.Monetary(string='Subtotal', readonly=True, store=True, @@ -126,11 +209,11 @@ class HotelService(models.Model): def _prepare_add_missing_fields(self, values): """ Deduce missing required fields from the onchange """ res = {} - onchange_fields = ['price_unit','tax_ids'] + onchange_fields = ['price_unit','tax_ids','name'] if values.get('product_id'): line = self.new(values) if any(f not in values for f in onchange_fields): - line.onchange_product_calc_qty() + line.onchange_product_id() for field in onchange_fields: if field not in values: res[field] = line._fields[field].convert_to_write(line[field], line) @@ -154,24 +237,28 @@ class HotelService(models.Model): 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.tax_ids = record.product_id.taxes_id.filtered(lambda r: not record.company_id or r.company_id == folio.company_id) + folio = record.folio_id or self.env['hotel.folio'].browse(self.env.context.get('default_folio_id')) + reservation = record.ser_room_line or self.env.context.get('ser_room_line') + origin = folio if folio else reservation + record.tax_ids = record.product_id.taxes_id.filtered(lambda r: not record.company_id or r.company_id == origin.company_id) @api.multi def _get_display_price(self, product): folio = self.folio_id or self.env.context.get('default_folio_id') - if folio.pricelist_id.discount_policy == 'with_discount': - return product.with_context(pricelist=folio.pricelist_id.id).price - product_context = dict(self.env.context, partner_id=folio.partner_id.id, date=folio.date_order, uom=self.product_id.uom_id.id) - final_price, rule_id = folio.pricelist_id.with_context(product_context).get_product_price_rule(self.product_id, self.product_qty or 1.0, folio.partner_id) - base_price, currency_id = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_qty, product_id.uom_id, folio.pricelist_id.id) - if currency_id != folio.pricelist_id.currency_id.id: - base_price = self.env['res.currency'].browse(currency_id).with_context(product_context).compute(base_price, folio.pricelist_id.currency_id) + reservation = self.ser_room_line or self.env.context.get('ser_room_line') + 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, 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) @api.onchange('product_id') - def onchange_product_calc_qty(self): + def onchange_product_id(self): """ Compute the default quantity according to the configuration of the selected product, in per_day product configuration, @@ -195,6 +282,30 @@ class HotelService(models.Model): for day in record.service_line_ids: day.no_free_resources() """ + Description and warnings + """ + product = self.product_id.with_context( + lang=self.folio_id.partner_id.lang, + partner=self.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 + vals['name'] = name + """ Compute tax and price unit """ self._compute_tax_ids() @@ -206,9 +317,10 @@ class HotelService(models.Model): self.ensure_one() folio = self.folio_id or self.env.context.get('default_folio_id') reservation = self.ser_room_line or self.env.context.get('ser_room_line') - if folio or reservation: - partner = folio.partner_id if folio else reservation.partner_id - pricelist = folio.pricelist_id if folio else reservation.pricelist_id + origin = folio if folio else reservation + if origin: + partner = origin.partner_id + pricelist = origin.pricelist_id if reservation and self.is_board_service: board_room_type = reservation.board_service_room_id if board_room_type.price_type == 'fixed': @@ -224,12 +336,12 @@ class HotelService(models.Model): lang=partner.lang, partner=partner.id, quantity=self.product_qty, - date=folio.date_order or fields.Date.today(), + date=folio.date_order if folio else fields.Date.today(), pricelist=pricelist.id, uom=self.product_id.uom_id.id, fiscal_position=False ) - return self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_ids, folio.company_id) + return self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_ids, origin.company_id) @api.model def prepare_service_lines(self, **kwargs): @@ -263,7 +375,7 @@ class HotelService(models.Model): Compute the amounts of the service line. """ for record in self: - folio = record.folio_id or self.env.context.get('default_folio_id') + folio = record.folio_id or self.env['hotel.folio'].browse(self.env.context.get('default_folio_id')) reservation = record.ser_room_line or self.env.context.get('ser_room_line') currency = folio.currency_id if folio else reservation.currency_id product = record.product_id diff --git a/hotel/models/inherited_account_invoice.py b/hotel/models/inherited_account_invoice.py index be8626210..1e67465fe 100644 --- a/hotel/models/inherited_account_invoice.py +++ b/hotel/models/inherited_account_invoice.py @@ -33,7 +33,6 @@ class AccountInvoice(models.Model): 'domain': [('id', 'in', payment_ids)], } - dif_customer_payment = fields.Boolean(compute='_compute_dif_customer_payment') from_folio = fields.Boolean(compute='_compute_dif_customer_payment') sale_ids = fields.Many2many( 'sale.order', 'sale_order_invoice_rel', 'invoice_id', @@ -45,16 +44,11 @@ class AccountInvoice(models.Model): @api.multi def _compute_dif_customer_payment(self): for inv in self: - sales = inv.mapped('invoice_line_ids.sale_line_ids.order_id') - folios = self.env['hotel.folio'].search([('order_id.id','in',sales.ids)]) + folios = inv.mapped('invoice_line_ids.reservation_ids.folio_id') + folios |= inv.mapped('invoice_line_ids.service_ids.folio_id') if folios: inv.from_folio = True inv.folio_ids = [(6, 0, folios.ids)] - payments_obj = self.env['account.payment'] - payments = payments_obj.search([('folio_id','in',folios.ids)]) - for pay in payments: - if pay.partner_id != inv.partner_id: - inv.dif_customer_payment = True @api.multi def action_invoice_open(self): diff --git a/hotel/models/inherited_account_invoice_line.py b/hotel/models/inherited_account_invoice_line.py new file mode 100644 index 000000000..3a5e6ff16 --- /dev/null +++ b/hotel/models/inherited_account_invoice_line.py @@ -0,0 +1,24 @@ +# Copyright 2017 Alexandre Díaz +# Copyright 2017 Dario Lodeiros +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import api, fields, models, _ + +class AccountInvoiceLine(models.Model): + _inherit = 'account.invoice.line' + + reservation_ids = fields.Many2many( + 'hotel.reservation', + 'reservation_invoice_rel', + 'invoice_line_id', 'reservation_id', + string='Reservations', readonly=True, copy=False) + service_ids = fields.Many2many( + 'hotel.service', + 'service_line_invoice_rel', + 'invoice_line_id', 'service_id', + string='Services', readonly=True, copy=False) + reservation_line_ids = fields.Many2many( + 'hotel.reservation.line', + 'reservation_line_invoice_rel', + 'invoice_line_id', 'reservation_line_id', + string='Reservation Lines', readonly=True, copy=False) + diff --git a/hotel/views/hotel_folio_views.xml b/hotel/views/hotel_folio_views.xml index 71d5cdc71..315445b0d 100644 --- a/hotel/views/hotel_folio_views.xml +++ b/hotel/views/hotel_folio_views.xml @@ -20,9 +20,9 @@ attrs="{'invisible': [('has_cancelled_reservations_to_send', '=', False)]}" class="oe_highlight"/> --> - + attrs="{'invisible': [('state', '!=', 'confirm')]}"/> - + - + - + - + @@ -132,10 +132,12 @@ + + @@ -150,315 +152,26 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Cancelled Reservation! - OverBooking! - - - - - - - - From to - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + nolabel="1" context="{'from_folio':True,'room_lines':room_lines,'folio_id': id,'tree_view_ref':'hotel.hotel_reservation_view_bottom_tree', 'form_view_ref':'hotel.hotel_reservation_view_form'}"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + context="{'from_room':False,'folio_id': id,'tree_view_ref':'hotel.hotel_service_view_tree', 'form_view_ref':'hotel.hotel_service_view_form'}" + nolabel="1" /> + + + + + + @@ -488,6 +201,7 @@ + @@ -506,7 +220,7 @@ - + diff --git a/hotel/views/hotel_reservation_views.xml b/hotel/views/hotel_reservation_views.xml index e8990fd1e..3cda84a48 100644 --- a/hotel/views/hotel_reservation_views.xml +++ b/hotel/views/hotel_reservation_views.xml @@ -6,6 +6,7 @@ hotel.reservation form tree,form,graph,pivot + {'from_room': True} @@ -17,6 +18,7 @@ + @@ -97,16 +99,15 @@ Books - + + ('pricelist_id', 'in', [pricelist_id, False])]" + options="{'no_create': True,'no_open': True}" /> + + @@ -240,55 +244,56 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -306,6 +311,7 @@ + @@ -331,6 +337,7 @@ + - @@ -363,8 +370,11 @@ - + + + + + + + hotel.reservation.tree + hotel.reservation + + primary + + + bottom + false + {'no_open': True} + Rooms + + + 1 + + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + + hotel.reservation.search diff --git a/hotel/views/hotel_service_views.xml b/hotel/views/hotel_service_views.xml index fc8409123..6429d99a1 100644 --- a/hotel/views/hotel_service_views.xml +++ b/hotel/views/hotel_service_views.xml @@ -41,6 +41,48 @@ + + + .hotel.service.view.tree + hotel.service + + + + + + + + + + + + + + + + + + + + + + + + + + + + hotel.service.search @@ -60,19 +102,6 @@ - - - hotel.service.tree - hotel.service - - - - - - - - - Hotel Services diff --git a/hotel/views/inherited_account_invoice_views.xml b/hotel/views/inherited_account_invoice_views.xml index f53262223..775bb1912 100644 --- a/hotel/views/inherited_account_invoice_views.xml +++ b/hotel/views/inherited_account_invoice_views.xml @@ -5,20 +5,15 @@ account.invoice - - - You have payments on the related folio associated with other customers than the current one on the invoice. - Make sure to from the folio if necessary before paying this invoice - - - - + + + + - {'invisible': ['|',('from_folio','=',True)]} + {'invisible': ['|',('from_folio','=',True)]} diff --git a/hotel/views/inherited_account_payment_views.xml b/hotel/views/inherited_account_payment_views.xml index 25fbfb56a..8f7a13be7 100644 --- a/hotel/views/inherited_account_payment_views.xml +++ b/hotel/views/inherited_account_payment_views.xml @@ -16,42 +16,52 @@ account.payment + + + + + - + - + + + + + + + - - - - - - - - + + + - + + + + + + + - - + + - - - - - - - - + + + + diff --git a/hotel/wizard/folio_make_invoice_advance.py b/hotel/wizard/folio_make_invoice_advance.py index 2797cf308..7448d558a 100644 --- a/hotel/wizard/folio_make_invoice_advance.py +++ b/hotel/wizard/folio_make_invoice_advance.py @@ -1,9 +1,11 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import time +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT from odoo import api, fields, models, _ import odoo.addons.decimal_precision as dp from odoo.exceptions import UserError +from datetime import timedelta class FolioAdvancePaymentInv(models.TransientModel): @@ -16,20 +18,38 @@ class FolioAdvancePaymentInv(models.TransientModel): @api.model def _get_advance_payment_method(self): - if self._count() == 1: - sale_obj = self.env['sale.order'] - folio_obj = self.env['hotel.folio'] - order = sale_obj.browse(folio_obj.mapped('order_id.id')) - if all([line.product_id.invoice_policy == 'order' for line in order.order_line]) \ - or order.invoice_count: - return 'all' - return 'delivered' + return 'all' @api.model def _default_product_id(self): - product_id = self.env['ir.default'].sudo().get('sale.config.settings', - 'deposit_product_id_setting') - return self.env['product.product'].browse(product_id) + product_id = self.env['ir.config_parameter'].sudo().get_param('sale.default_deposit_product_id') + return self.env['product.product'].browse(int(product_id)) + + @api.model + def _get_default_folio(self): + if self._context.get('default_reservation_id'): + folio_ids = self._context.get('default_folio_id', []) + else: + folio_ids = self._context.get('active_ids', []) + + folios = self.env['hotel.folio'].browse(folio_ids) + return folios + + @api.model + def _get_default_reservation(self): + if self._context.get('default_reservation_id'): + reservations = self.env['hotel.reservation'].browse(self._context.get('active_ids', [])) + else: + folios = self._get_default_folio() + reservations = self.env['hotel.reservation'] + for folio in folios: + reservations |= folio.room_lines + return reservations + + @api.model + def _get_default_partner_invoice(self): + folios = self._get_default_folio() + return folios[0].partner_invoice_id @api.model def _default_deposit_account_id(self): @@ -40,15 +60,30 @@ class FolioAdvancePaymentInv(models.TransientModel): return self._default_product_id().taxes_id advance_payment_method = fields.Selection([ - ('delivered', 'Invoiceable lines'), ('all', 'Invoiceable lines (deduct down payments)'), + ('one', 'One line (Bill all in one line)'), ('percentage', 'Down payment (percentage)'), ('fixed', 'Down payment (fixed amount)') ], string='What do you want to invoice?', default=_get_advance_payment_method, required=True) - product_id = fields.Many2one('product.product', string='Down Payment Product', - domain=[('type', '=', 'service')], default=_default_product_id) count = fields.Integer(default=_count, string='# of Orders') + folio_ids = fields.Many2many("hotel.folio", string="Folios", + help="Folios grouped", + default=_get_default_folio) + reservation_ids = fields.Many2many("hotel.reservation", string="Rooms", + help="Folios grouped", + default=_get_default_reservation) + group_folios = fields.Boolean('Group Folios') + partner_invoice_id = fields.Many2one('res.partner', + string='Invoice Address', required=True, + default=_get_default_partner_invoice, + help="Invoice address for current Invoice.") + line_ids = fields.One2many('line.advance.inv', + 'advance_inv_id', + string="Invoice Lines") + #Advance Payment + product_id = fields.Many2one('product.product', string="Product", + domain=[('type', '=', 'service')], default=_default_product_id) amount = fields.Float('Down Payment Amount', digits=dp.get_precision('Account'), help="The amount to be invoiced in advance, taxes excluded.") @@ -107,7 +142,6 @@ class FolioAdvancePaymentInv(models.TransientModel): 'reference': False, 'account_id': order.partner_id.property_account_receivable_id.id, 'partner_id': order.partner_invoice_id.id, - 'partner_shipping_id': order.partner_shipping_id.id, 'invoice_line_ids': [(0, 0, { 'name': name, 'origin': order.name, @@ -119,7 +153,7 @@ class FolioAdvancePaymentInv(models.TransientModel): 'product_id': self.product_id.id, 'sale_line_ids': [(6, 0, [so_line.id])], 'invoice_line_tax_ids': [(6, 0, tax_ids)], - 'account_analytic_id': order.project_id.id or False, + 'account_analytic_id': order.analytic_account_id.id or False, })], 'currency_id': order.pricelist_id.currency_id.id, 'payment_term_id': order.payment_term_id.id, @@ -136,29 +170,58 @@ class FolioAdvancePaymentInv(models.TransientModel): subtype_id=self.env.ref('mail.mt_note').id) return invoice + @api.model + def _validate_invoices(self, invoice): + invoice.action_invoice_open() + payment_ids = self.folio_ids.mapped('payment_ids.id') + domain = [('account_id', '=', invoice.account_id.id), + ('payment_id', 'in', payment_ids), ('reconciled', '=', False), + '|', ('amount_residual', '!=', 0.0), + ('amount_residual_currency', '!=', 0.0)] + if invoice.type in ('out_invoice', 'in_refund'): + domain.extend([('credit', '>', 0), ('debit', '=', 0)]) + type_payment = _('Outstanding credits') + else: + domain.extend([('credit', '=', 0), ('debit', '>', 0)]) + type_payment = _('Outstanding debits') + info = {'title': '', 'outstanding': True, 'content': [], 'invoice_id': invoice.id} + lines = self.env['account.move.line'].search(domain) + currency_id = invoice.currency_id + for line in lines: + invoice.assign_outstanding_credit(line.id) + @api.multi def create_invoices(self): - folios = self.env['hotel.folio'].browse(self._context.get('active_ids', [])) - sale_orders = self.env['sale.order'].browse(folios.mapped('order_id.id')) + inv_obj = self.env['account.invoice'] + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + folios = self.folio_ids - if self.advance_payment_method == 'delivered': - sale_orders.action_invoice_create() + for folio in folios: + if folio.partner_invoice_id != self.partner_invoice_id: + raise UserError(_('The billing directions must match')) + + if self.advance_payment_method == 'all': + inv_data = self._prepare_invoice() + invoice = inv_obj.create(inv_data) + for line in self.line_ids: + line.invoice_line_create(invoice.id, line.qty) + self._validate_invoices(invoice) + elif self.advance_payment_method == 'all': - sale_orders.action_invoice_create(final=True) + pass + #Group lines by tax_ids 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.default'].sudo().set( - 'sale.config.settings', - 'deposit_product_id_setting', - self.product_id.id) + self.env['ir.config_parameter'].sudo().set_param( + 'sale.default_deposit_product_id', self.product_id.id) - sale_line_obj = self.env['sale.order.line'] - for order in sale_orders: + service_obj = self.env['hotel.service'] + for folio in folios: if self.advance_payment_method == 'percentage': - amount = order.amount_untaxed * self.amount / 100 + amount = folio.amount_untaxed * folio.amount_total / 100 else: amount = self.amount if self.product_id.invoice_policy != 'order': @@ -166,26 +229,43 @@ class FolioAdvancePaymentInv(models.TransientModel): 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) - if order.fiscal_position_id and taxes: - tax_ids = order.fiscal_position_id.map_tax(taxes).ids + lambda r: not folio.company_id or r.company_id == folio.company_id) + if folio.fiscal_position_id and taxes: + tax_ids = folio.fiscal_position_id.map_tax(taxes).ids else: tax_ids = taxes.ids - context = {'lang': order.partner_id.lang} - so_line = sale_line_obj.create({ + context = {'lang': folio.partner_id.lang} + service_line = service_obj.create({ 'name': _('Advance: %s') % (time.strftime('%m %Y'),), 'price_unit': amount, 'product_uom_qty': 0.0, - 'order_id': order.id, + 'folio_id': folio.id, 'discount': 0.0, 'product_uom': self.product_id.uom_id.id, 'product_id': self.product_id.id, 'tax_id': [(6, 0, tax_ids)], }) del context - self._create_invoice(order, so_line, amount) + invoice = self._create_invoice(folio, service_line, amount) + invoice.compute_taxes() + if not invoice.invoice_line_ids: + raise UserError(_('There is no invoiceable line.')) + # If invoice is negative, do a refund invoice instead + if invoice.amount_total < 0: + invoice.type = 'out_refund' + for line in invoice.invoice_line_ids: + line.quantity = -line.quantity + # Use additional field helper function (for account extensions) + for line in invoice.invoice_line_ids: + line._set_additional_fields(invoice) + # Necessary to force computation of taxes. In account_invoice, they are triggered + # by onchanges, which are not triggered when doing a create. + invoice.compute_taxes() + invoice.message_post_with_view('mail.message_origin_link', + values={'self': invoice, 'origin': folios}, + subtype_id=self.env.ref('mail.mt_note').id) if self._context.get('open_invoices', False): - return sale_orders.action_view_invoice() + return folios.open_invoices_folio() return {'type': 'ir.actions.act_window_close'} def _prepare_deposit_product(self): @@ -196,3 +276,226 @@ class FolioAdvancePaymentInv(models.TransientModel): 'property_account_income_id': self.deposit_account_id.id, 'taxes_id': [(6, 0, self.deposit_taxes_id.ids)], } + + @api.onchange('reservation_ids') + def prepare_invoice_lines(self): + vals = [] + folios = self.folio_ids + invoice_lines = {} + for folio in folios: + for service in folio.service_ids.filtered( + lambda x: x.is_board_service == False and \ + (x.ser_room_line.id in self.reservation_ids.ids or \ + x.ser_room_line.id == False)): + invoice_lines[service.id] = { + 'description' : service.name, + 'product_id': service.product_id.id, + 'qty': service.product_qty, + 'discount': service.discount, + 'price_unit': service.price_unit, + 'service_id': service.id, + } + for reservation in folio.room_lines.filtered( + lambda x: x.id in self.reservation_ids.ids): + board_service = reservation.board_service_room_id + for day in reservation.reservation_line_ids.sorted('date'): + extra_price = 0 + if board_service: + services = reservation.service_ids.filtered( + lambda x: x.is_board_service == True) + for service in services: + extra_price += service.price_unit * \ + service.service_line_ids.filtered( + lambda x: x.date == day.date).day_qty + #group_key: if group by reservation, We no need group by room_type + group_key = (reservation.id, reservation.room_type_id.id, day.price + extra_price, day.discount) + date = fields.Date.from_string(day.date) + description = folio.name + ' ' + reservation.room_type_id.name + ' (' + \ + reservation.board_service_room_id.hotel_board_service_id.name + ')' \ + if board_service else folio.name + ' ' + reservation.room_type_id.name + if group_key not in invoice_lines: + invoice_lines[group_key] = { + 'description' : description, + 'reservation_id': reservation.id, + 'room_type_id': reservation.room_type_id, + 'product_id': self.env['product.product'].browse( + reservation.room_type_id.product_id.id), + 'discount': day.discount, + 'price_unit': day.price + extra_price, + 'reservation_line_ids': [(4, day.id)] + } + else: + invoice_lines[group_key][('reservation_line_ids')].append((4,day.id)) + for group_key in invoice_lines: + vals.append((0, False, invoice_lines[group_key])) + self.line_ids = vals + self.line_ids.onchange_reservation_line_ids() + + @api.onchange('folio_ids') + def onchange_folio_ids(self): + vals = [] + folios = self.folio_ids + invoice_lines = {} + reservations = self.env['hotel.reservation'] + services = self.env['hotel.service'] + old_folio_ids = self.reservation_ids.mapped('folio_id.id') + for folio in folios.filtered(lambda r: r.id not in old_folio_ids): + folio_reservations = folio.room_lines + if folio_reservations: + reservations |= folio_reservations + self.reservation_ids |= reservations + self.prepare_invoice_lines() + + @api.model + 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). + """ + + journal_id = self.env['account.invoice'].default_get(['journal_id'])['journal_id'] + if not journal_id: + raise UserError(_('Please define an accounting sales journal for this company.')) + origin = ' '.join(self.folio_ids.mapped('name')) + pricelist = self.folio_ids[0].pricelist_id + currency = self.folio_ids[0].currency_id + payment_term = self.folio_ids[0].payment_term_id + fiscal_position = self.folio_ids[0].fiscal_position_id + company = self.folio_ids[0].company_id + user = self.folio_ids[0].user_id + team = self.folio_ids[0].team_id + for folio in self.folio_ids: + if folio.pricelist_id != pricelist: + raise UserError(_('All Folios must hace the same pricelist')) + invoice_vals = { + 'name': self.folio_ids[0].client_order_ref or '', + 'origin': origin, + 'type': 'out_invoice', + 'account_id': self.partner_invoice_id.property_account_receivable_id.id, + 'partner_id': self.partner_invoice_id.id, + 'journal_id': journal_id, + 'currency_id': pricelist.id, + 'payment_term_id': payment_term.id, + 'fiscal_position_id': fiscal_position.id or self.partner_invoice_id.property_account_position_id.id, + 'company_id': company.id, + 'user_id': user and user.id, + 'team_id': team.id + } + return invoice_vals + +class LineAdvancePaymentInv(models.TransientModel): + _name = "line.advance.inv" + _description = "Lines Advance Invoice" + + room_type_id = fields.Many2one('hotel.room.type') + product_id = fields.Many2one('product.product', string='Down Payment Product', + domain=[('type', '=', 'service')]) + qty = fields.Integer('Quantity') + price_unit = fields.Float('Price Unit') + price_total = fields.Float('Price Total', compute='_compute_price_total') + price_tax = fields.Float('Price Tax', compute='_compute_price_total') + price_subtotal = fields.Float('Price Subtotal', + compute='_compute_price_total', + store=True) + advance_inv_id = fields.Many2one('folio.advance.payment.inv') + price_room = fields.Float(compute='_compute_price_room') + discount = fields.Float( + string='Discount (%)', + digits=dp.get_precision('Discount'), default=0.0) + to_invoice = fields.Boolean('To Invoice') + description = fields.Text('Description') + description_dates = fields.Text('Range') + reservation_id = fields.Many2one('hotel.reservation') + service_id = fields.Many2one('hotel.service') + folio_id = fields.Many2one('hotel.folio', compute='_compute_folio_id') + reservation_line_ids = fields.Many2many( + 'hotel.reservation.line', + string='Reservation Lines') + + @api.depends('qty', 'price_unit', 'discount') + def _compute_price_total(self): + for record in self: + origin = record.reservation_id if record.reservation_id.id else record.service_id + amount_line = record.price_unit * record.qty + if amount_line > 0: + product = record.product_id + price = amount_line * (1 - (record.discount or 0.0) * 0.01) + taxes = origin.tax_ids.compute_all(price, origin.currency_id, 1, product=product) + record.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'], + }) + + def _compute_price_room(self): + for record in self: + if record.reservation_id: + record.price_room = record.reservation_line_ids[0].price + + def _compute_folio_id(self): + for record in self: + origin = record.reservation_id if record.reservation_id.id else record.service_id + record.folio_id = origin.folio_id + + @api.onchange('reservation_line_ids') + def onchange_reservation_line_ids(self): + for record in self: + if record.reservation_id: + if not record.reservation_line_ids: + raise UserError(_('If you want drop the line, use the trash icon')) + record.qty = len(record.reservation_line_ids) + record.description_dates = record.reservation_line_ids[0].date + ' - ' + \ + ((fields.Date.from_string(record.reservation_line_ids[-1].date)) + \ + timedelta(days=1)).strftime(DEFAULT_SERVER_DATE_FORMAT) + + @api.multi + def invoice_line_create(self, invoice_id, qty): + """ Create an invoice line. + :param invoice_id: integer + :param qty: float quantity to invoice + :returns recordset of account.invoice.line created + """ + invoice_lines = self.env['account.invoice.line'] + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + for line in self: + origin = line.reservation_id if line.reservation_id.id else line.service_id + res = {} + product = line.product_id + account = product.property_account_income_id or product.categ_id.property_account_income_categ_id + if not account: + raise UserError(_('Please define income account for this product: "%s" (id:%d) - or for its category: "%s".') % + (product.name, product.id, product.categ_id.name)) + + fpos = line.folio_id.fiscal_position_id or line.folio_id.partner_id.property_account_position_id + if fpos: + account = fpos.map_account(account) + vals = { + 'sequence': origin.sequence, + 'origin': origin.name, + 'account_id': account.id, + 'price_unit': line.price_unit, + 'quantity': line.qty, + 'discount': line.discount, + 'uom_id': product.uom_id.id, + 'product_id': product.id or False, + 'invoice_line_tax_ids': [(6, 0, origin.tax_ids.ids)], + 'account_analytic_id': line.folio_id.analytic_account_id.id, + 'analytic_tag_ids': [(6, 0, origin.analytic_tag_ids.ids)] + } + if line.reservation_id: + vals.update({ + 'name': line.description + ' (' + line.description_dates + ')', + 'invoice_id': invoice_id, + 'reservation_ids': [(6, 0, [origin.id])], + 'reservation_line_ids': [(6, 0, line.reservation_line_ids.ids)] + }) + elif line.service_id: + vals.update({ + 'name': line.description, + 'invoice_id': invoice_id, + 'service_ids': [(6, 0, [origin.id])] + }) + invoice_lines |= self.env['account.invoice.line'].create(vals) + + return invoice_lines diff --git a/hotel/wizard/folio_make_invoice_advance_views.xml b/hotel/wizard/folio_make_invoice_advance_views.xml index 110dfbde3..690803d57 100644 --- a/hotel/wizard/folio_make_invoice_advance_views.xml +++ b/hotel/wizard/folio_make_invoice_advance_views.xml @@ -4,30 +4,67 @@ Invoice Orders folio.advance.payment.inv - - - Invoices will be created in draft so that you can review - them before validation. - + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
- Invoices will be created in draft so that you can review - them before validation. -