File: hr_expense_sheet.py

package info (click to toggle)
odoo 18.0.0%2Bdfsg-2
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 878,716 kB
  • sloc: javascript: 927,937; python: 685,670; xml: 388,524; sh: 1,033; sql: 415; makefile: 26
file content (875 lines) | stat: -rw-r--r-- 39,714 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import api, fields, Command, models, _
from odoo.exceptions import AccessError, UserError, ValidationError, RedirectWarning
from odoo.tools.misc import clean_context
from odoo.tools import format_date


class HrExpenseSheet(models.Model):
    """
        Here are the rights associated with the expense flow

        Action       Group                   Restriction
        =================================================================================
        Submit      Employee                Only his own
                    Officer                 If he is expense manager of the employee, manager of the employee
                                             or the employee is in the department managed by the officer
                    Manager                 Always
        Approve     Officer                 Not his own and he is expense manager of the employee, manager of the employee
                                             or the employee is in the department managed by the officer
                    Manager                 Always
        Post        Anybody                 State = approve and journal_id defined
        Done        Anybody                 State = approve and journal_id defined
        Cancel      Officer                 Not his own and he is expense manager of the employee, manager of the employee
                                             or the employee is in the department managed by the officer
                    Manager                 Always
        =================================================================================
    """
    _name = "hr.expense.sheet"
    _inherit = ['mail.thread.main.attachment', 'mail.activity.mixin']
    _description = "Expense Report"
    _order = "accounting_date desc, id desc"
    _check_company_auto = True

    @api.model
    def _default_employee_id(self):
        return self.env.user.employee_id

    @api.model
    def _default_journal_id(self):
        """
             The journal is determining the company of the accounting entries generated from expense.
             We need to force journal company and expense sheet company to be the same.
        """
        company_journal_id = self.env.company.expense_journal_id
        if company_journal_id:
            return company_journal_id.id
        default_company_id = self.default_get(['company_id'])['company_id']
        journal = self.env['account.journal'].search([
            *self.env['account.journal']._check_company_domain(default_company_id),
            ('type', '=', 'purchase'),
        ], limit=1)
        return journal.id

    name = fields.Char(string="Expense Report Summary", required=True, tracking=True)
    expense_line_ids = fields.One2many(
        comodel_name='hr.expense', inverse_name='sheet_id',
        string="Expense Lines",
        copy=False,
    )
    nb_expense = fields.Integer(compute='_compute_nb_expense', string="Number of Expenses")
    state = fields.Selection(
        selection=[
            ('draft', 'To Submit'),
            ('submit', 'Submitted'),
            ('approve', 'Approved'),
            ('post', 'Posted'),
            ('done', 'Done'),
            ('cancel', 'Refused')
        ],
        string="Status",
        compute='_compute_state', store=True, readonly=True,
        index=True,
        required=True,
        default='draft',
        tracking=True,
        copy=False,
    )
    approval_state = fields.Selection(
        selection=[
            ('submit', 'Submitted'),
            ('approve', 'Approved'),
            ('cancel', 'Refused'),
        ],
        copy=False,
    )
    approval_date = fields.Datetime(string="Approval Date", readonly=True)
    company_id = fields.Many2one(
        comodel_name='res.company',
        string="Company",
        required=True,
        readonly=True,
        default=lambda self: self.env.company,
    )
    employee_id = fields.Many2one(
        comodel_name='hr.employee',
        string="Employee",
        required=True,
        readonly=True,
        default=_default_employee_id,
        domain=[('filter_for_expense', '=', True)],
        check_company=True,
        tracking=True,
    )

    department_id = fields.Many2one(
        comodel_name='hr.department',
        related='employee_id.department_id',
        string="Department",
        store=True,
        copy=False,
    )
    user_id = fields.Many2one(
        comodel_name='res.users',
        string="Manager",
        compute='_compute_from_employee_id', store=True, readonly=True,
        domain=lambda self: [('groups_id', 'in', self.env.ref('hr_expense.group_hr_expense_team_approver').id)],
        copy=False,
        tracking=True,
    )
    product_ids = fields.Many2many(
        comodel_name='product.product',
        string="Categories",
        compute='_compute_product_ids',
        search='_search_product_ids',
        check_company=True,
    )

    # === Amount fields === #
    total_amount = fields.Monetary(
        string="Total",
        currency_field='company_currency_id',
        compute='_compute_amount', store=True, readonly=True,
        tracking=True,
    )
    untaxed_amount = fields.Monetary(
        string="Untaxed Amount",
        currency_field='company_currency_id',
        compute='_compute_amount', store=True, readonly=True,
    )
    total_tax_amount = fields.Monetary(
        string="Taxes",
        currency_field='company_currency_id',
        compute='_compute_amount', store=True, readonly=True,
    )
    amount_residual = fields.Monetary(
        string="Amount Due",
        currency_field='company_currency_id',
        compute='_compute_from_account_move_ids', store=True, readonly=True,
    )
    currency_id = fields.Many2one(
        comodel_name='res.currency',
        string="Currency",
        compute='_compute_currency_id', store=True, readonly=True,
    )
    company_currency_id = fields.Many2one(
        comodel_name='res.currency',
        related='company_id.currency_id',
        string="Report Company Currency"
    )
    is_multiple_currency = fields.Boolean(
        string="Handle lines with different currencies",
        compute='_compute_is_multiple_currency',
    )

    # === Account fields === #
    payment_state = fields.Selection(
        selection=lambda self: self.env["account.move"]._fields["payment_state"]._description_selection(self.env),
        string="Payment Status",
        compute='_compute_from_account_move_ids', store=True, readonly=True,
        copy=False,
        tracking=True,
    )
    payment_mode = fields.Selection(
        related='expense_line_ids.payment_mode',
        string="Paid By",
        tracking=True,
        readonly=True,
    )
    employee_journal_id = fields.Many2one(
        comodel_name='account.journal',
        string="Journal",
        default=_default_journal_id,
        check_company=True,
        domain=[('type', '=', 'purchase')],
        help="The journal used when the expense is paid by employee.",
    )
    selectable_payment_method_line_ids = fields.Many2many(
        comodel_name='account.payment.method.line',
        compute='_compute_selectable_payment_method_line_ids',
    )
    payment_method_line_id = fields.Many2one(
        comodel_name='account.payment.method.line',
        string="Payment Method",
        compute='_compute_payment_method_line_id', store=True, readonly=False,
        domain="[('id', 'in', selectable_payment_method_line_ids)]",
        help="The payment method used when the expense is paid by the company.",
    )
    attachment_ids = fields.One2many(
        comodel_name='ir.attachment',
        inverse_name='res_id',
        domain="[('res_model', '=', 'hr.expense.sheet')]",
        string='Attachments of expenses',
    )
    message_main_attachment_id = fields.Many2one(compute='_compute_main_attachment', store=True)
    accounting_date = fields.Date(string="Expense Report Date", help="Specify the bill date of the related vendor bill.")
    account_move_ids = fields.One2many(
        string="Journal Entries",
        comodel_name='account.move', inverse_name='expense_sheet_id', readonly=True,
    )
    nb_account_move = fields.Integer(string="Number of Journal Entries", compute='_compute_nb_account_move')
    journal_id = fields.Many2one(
        comodel_name='account.journal',
        string="Expense Journal",
        compute='_compute_journal_id', store=True,
        check_company=True,
    )

    # === Security fields === #
    can_reset = fields.Boolean(string='Can Reset', compute='_compute_can_reset')
    can_approve = fields.Boolean(string='Can Approve', compute='_compute_can_approve')
    cannot_approve_reason = fields.Char(string='Cannot Approve Reason', compute='_compute_can_approve')
    is_editable = fields.Boolean(string="Expense Lines Are Editable By Current User", compute='_compute_is_editable')

    _sql_constraints = [(
        'journal_id_required_posted',
        "CHECK((state IN ('post', 'done') AND journal_id IS NOT NULL) OR (state NOT IN ('post', 'done')))",
        'The journal must be set on posted expense'
    )]

    @api.depends('expense_line_ids.total_amount', 'expense_line_ids.tax_amount')
    def _compute_amount(self):
        for sheet in self:
            sheet.total_amount = sum(sheet.expense_line_ids.mapped('total_amount'))
            sheet.total_tax_amount = sum(sheet.expense_line_ids.mapped('tax_amount'))
            sheet.untaxed_amount = sheet.total_amount - sheet.total_tax_amount

    @api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual')
    def _compute_from_account_move_ids(self):
        for sheet in self:
            if sheet.payment_mode == 'company_account':
                if sheet.account_move_ids.filtered(lambda move: move.state != 'draft'):
                    # when the sheet is paid by the company, the state/amount of the related account_move_ids are not relevant
                    # unless all moves have been reversed
                    sheet.amount_residual = 0.
                    if sheet.account_move_ids - sheet.account_move_ids.filtered('reversal_move_ids'):
                        sheet.payment_state = 'paid'
                    else:
                        sheet.payment_state = 'reversed'
                else:
                    sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
                    payment_states = set(sheet.account_move_ids.mapped('payment_state'))
                    if len(payment_states) <= 1:  # If only 1 move or only one state
                        sheet.payment_state = payment_states.pop() if payment_states else 'not_paid'
                    elif 'partial' in payment_states or 'paid' in payment_states:  # else if any are (partially) paid
                        sheet.payment_state = 'partial'
                    else:
                        sheet.payment_state = 'not_paid'
            else:
                # Only one move is created when the expenses are paid by the employee
                if sheet.account_move_ids.filtered(lambda move: move.state == 'posted'):
                    sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
                    sheet.payment_state = sheet.account_move_ids[:1].payment_state
                else:
                    sheet.amount_residual = 0.0
                    sheet.payment_state = 'not_paid'

    @api.depends('selectable_payment_method_line_ids')
    def _compute_payment_method_line_id(self):
        for sheet in self:
            sheet.payment_method_line_id = sheet.selectable_payment_method_line_ids[:1]

    @api.depends('employee_journal_id', 'payment_method_line_id')
    def _compute_journal_id(self):
        for sheet in self:
            if sheet.payment_mode == 'company_account':
                sheet.journal_id = sheet.payment_method_line_id.journal_id
            else:
                sheet.journal_id = sheet.employee_journal_id

    @api.depends('company_id')
    def _compute_selectable_payment_method_line_ids(self):
        for sheet in self:
            allowed_method_line_ids = sheet.company_id.company_expense_allowed_payment_method_line_ids
            if allowed_method_line_ids:
                sheet.selectable_payment_method_line_ids = allowed_method_line_ids
            else:
                sheet.selectable_payment_method_line_ids = self.env['account.payment.method.line'].search([
                    ('payment_type', '=', 'outbound'),
                    ('company_id', 'parent_of', sheet.company_id.id)
                ])

    @api.depends('account_move_ids', 'payment_state', 'approval_state')
    def _compute_state(self):
        for sheet in self:
            move_ids = sheet.account_move_ids
            if not sheet.approval_state:
                sheet.state = 'draft'
            elif sheet.approval_state == 'cancel':
                sheet.state = 'cancel'
            elif move_ids:
                if sheet.payment_state != 'not_paid':
                    sheet.state = 'done'
                elif all(move_ids.mapped(lambda move: move.state == 'draft')):
                    sheet.state = 'approve'
                else:
                    sheet.state = 'post'
            else:
                sheet.state = sheet.approval_state  # Submit & approved without a move case

    @api.depends('expense_line_ids.attachment_ids')
    def _compute_main_attachment(self):
        for sheet in self:
            attachments = sheet.attachment_ids
            if not sheet.message_main_attachment_id or sheet.message_main_attachment_id not in attachments:
                expenses = sheet.expense_line_ids
                expenses_mma_checksums = expenses.message_main_attachment_id.mapped('checksum')
                sheet.message_main_attachment_id = attachments.filtered(
                    lambda att: att.checksum in expenses_mma_checksums
                )[:1] or attachments[:1]

    @api.depends('expense_line_ids.currency_id', 'company_currency_id')
    def _compute_currency_id(self):
        for sheet in self:
            if not sheet.expense_line_ids or sheet.is_multiple_currency or sheet.payment_mode == 'own_account':
                sheet.currency_id = sheet.company_currency_id
            else:
                sheet.currency_id = sheet.expense_line_ids[:1].currency_id

    @api.depends('expense_line_ids.currency_id')
    def _compute_is_multiple_currency(self):
        for sheet in self:
            sheet.is_multiple_currency = any(sheet.expense_line_ids.mapped('is_multiple_currency')) \
                                         or len(sheet.expense_line_ids.mapped('currency_id')) > 1

    @api.depends('employee_id')
    def _compute_can_reset(self):
        is_expense_user = self.env.user.has_group('hr_expense.group_hr_expense_team_approver')
        for sheet in self:
            sheet.can_reset = is_expense_user if is_expense_user else sheet.employee_id.user_id == self.env.user

    @api.depends_context('uid')
    @api.depends('employee_id')
    def _compute_can_approve(self):
        is_team_approver = self.env.user.has_group('hr_expense.group_hr_expense_team_approver')
        is_approver = self.env.user.has_group('hr_expense.group_hr_expense_user')
        is_hr_admin = self.env.user.has_group('hr_expense.group_hr_expense_manager')

        for sheet in self:
            reason = False
            if not is_team_approver:
                reason = _("%s: Your are not a Manager or HR Officer", sheet.name)

            elif not is_hr_admin:
                sheet_employee = sheet.employee_id
                current_managers = sheet_employee.expense_manager_id \
                                   | sheet_employee.parent_id.user_id \
                                   | sheet_employee.department_id.manager_id.user_id \
                                   | sheet.user_id

                if sheet_employee.user_id == self.env.user:
                    reason = _("%s: It is your own expense", sheet.name)

                elif self.env.user not in current_managers and not is_approver and sheet_employee.expense_manager_id.id != self.env.user.id:
                    reason = _("%s: It is not from your department", sheet.name)

            sheet.can_approve = not reason
            sheet.cannot_approve_reason = reason

    @api.depends('expense_line_ids')
    def _compute_nb_expense(self):
        for sheet in self:
            sheet.nb_expense = len(sheet.expense_line_ids)

    @api.depends('account_move_ids')
    def _compute_nb_account_move(self):
        for sheet in self:
            sheet.nb_account_move = len(sheet.account_move_ids)

    @api.depends('employee_id', 'employee_id.department_id')
    def _compute_from_employee_id(self):
        for sheet in self:
            sheet.department_id = sheet.employee_id.department_id
            sheet.user_id = sheet.employee_id.expense_manager_id or sheet.employee_id.parent_id.user_id

    @api.depends_context('uid')
    @api.depends('employee_id', 'user_id', 'state')
    def _compute_is_editable(self):
        is_hr_admin = (
                self.env.user.has_group('hr_expense.group_hr_expense_manager')
                or self.env.user.has_group('base.group_system')
        )
        is_approver = self.env.user.has_group('hr_expense.group_hr_expense_user')
        for sheet in self:
            if sheet.state not in {'draft', 'submit', 'approve'}:
                # Not editable
                sheet.is_editable = False
                continue

            if is_hr_admin or self.env.su:
                # Administrator-level users are not restricted
                sheet.is_editable = True
                continue

            employee = sheet.employee_id

            is_own_sheet = employee.user_id == self.env.user
            if is_own_sheet and sheet.state == 'draft':
                # Anyone can edit their own draft sheet
                sheet.is_editable = True
                continue

            managers = employee.expense_manager_id | employee.parent_id.user_id | employee.department_id.manager_id.user_id
            if is_approver:
                managers |= self.env.user
            if not is_own_sheet and self.env.user in managers:
                # If Approver-level or designated manager, can edit other people sheet
                sheet.is_editable = True
                continue
            sheet.is_editable = False

    @api.constrains('expense_line_ids')
    def _check_payment_mode(self):
        for sheet in self:
            expense_lines = sheet.mapped('expense_line_ids')
            if expense_lines and any(expense.payment_mode != expense_lines[:1].payment_mode for expense in expense_lines):
                raise ValidationError(_("All expenses in an expense report must have the same \"paid by\" criteria."))

    @api.depends('expense_line_ids')
    def _compute_product_ids(self):
        for sheet in self:
            sheet.product_ids = sheet.expense_line_ids.mapped('product_id')

    @api.constrains('expense_line_ids', 'employee_id')
    def _check_employee(self):
        for sheet in self:
            if sheet.expense_line_ids.employee_id - sheet.employee_id:
                raise ValidationError(_('You cannot add expenses of another employee.'))

    @api.constrains('expense_line_ids', 'company_id')
    def _check_expense_lines_company(self):
        for sheet in self:
            if sheet.expense_line_ids.company_id - sheet.company_id:
                raise ValidationError(_('An expense report must contain only lines from the same company.'))

    @api.onchange('expense_line_ids')
    def _update_sheet_name(self):
        """ Set the sheet name to the computed default sheet name when no name is specified. """
        expense_lines = self.expense_line_ids
        if not self.name and expense_lines:
            self.name = self._get_default_sheet_name(expense_lines)

    @api.model
    def _get_default_sheet_name(self, expenses_to_report):
        """ Computes the default name for a new expense sheet from the expenses name or dates """
        if len(expenses_to_report) == 1:
            sheet_name = expenses_to_report.name
        else:
            dates = expenses_to_report.mapped('date')
            if False in dates:  # If at least one date isn't set, we don't set a default name
                return False
            min_date = format_date(self.env, min(dates))
            max_date = format_date(self.env, max(dates))
            if min_date == max_date:
                sheet_name = min_date
            else:
                sheet_name = _("%(date_from)s - %(date_to)s", date_from=min_date, date_to=max_date)
        return sheet_name

    @api.model
    def _search_product_ids(self, operator, value):
        if operator == 'in' and not isinstance(value, list):
            value = [value]
        return [('expense_line_ids.product_id', operator, value)]

    # ----------------------------------------
    # ORM Overrides
    # ----------------------------------------

    @api.model_create_multi
    def create(self, vals_list):
        context = clean_context(self.env.context)
        context.update({
            'mail_create_nosubscribe': True,
            'mail_auto_subscribe_no_notify': True,
        })
        sheets = super(HrExpenseSheet, self.with_context(context)).create(vals_list)
        sheets.activity_update()
        return sheets

    def write(self, values):
        res = super().write(values)

        user_is_accountant = self.env.user.has_group('account.group_account_user')
        edit_lines = 'expense_line_ids' in values
        edit_states = 'state' in values or 'approval_state' in values
        # Forbids (un)linking expenses from an approved sheet if you're not an accountant
        if edit_lines and not user_is_accountant and set(self.mapped('state')) - {'draft', 'submit'}:
            raise AccessError(_("You do not have the rights to add or remove any expenses on an approved or paid expense report."))

        # Ensures there is no empty expense report in a state different from draft or cancel
        if edit_states or edit_lines:
            for sheet in self.filtered(lambda sheet: not sheet.expense_line_ids):
                if sheet.state in {'submit', 'approve', 'post', 'done'}:  # Empty expense report in a state different from draft or cancel
                    if edit_lines and not sheet.expense_line_ids:  # If you try to remove all expenses from the sheet
                        raise UserError(_("You cannot remove all expenses from a submitted, approved or paid expense report."))
                    else:  # If you try to submit, approve, post or pay an empty sheet
                        raise UserError(_("This expense report is empty. You cannot submit or approve an empty expense report."))
        return res

    @api.ondelete(at_uninstall=False)
    def _unlink_except_posted_or_paid(self):
        for expense in self:
            if expense.state in {'post', 'done'}:
                raise UserError(_('You cannot delete a posted or paid expense.'))

    # --------------------------------------------
    # Mail Thread
    # --------------------------------------------

    def _track_subtype(self, init_values):
        self.ensure_one()
        if 'state' not in init_values:
            return super()._track_subtype(init_values)

        match self.state:
            case 'draft':
                return self.env.ref('hr_expense.mt_expense_reset')
            case 'cancel':
                return self.env.ref('hr_expense.mt_expense_refused')
            case 'done':
                return self.env.ref('hr_expense.mt_expense_paid')
            case 'approve':
                if init_values['state'] in {'post', 'done'}:  # Reverting state
                    subtype = 'hr_expense.mt_expense_entry_draft' if self.account_move_ids else 'hr_expense.mt_expense_entry_delete'
                    return self.env.ref(subtype)
                return self.env.ref('hr_expense.mt_expense_approved')
            case _:
                return super()._track_subtype(init_values)

    def _message_auto_subscribe_followers(self, updated_values, subtype_ids):
        res = super()._message_auto_subscribe_followers(updated_values, subtype_ids)
        if updated_values.get('employee_id'):
            employee_user = self.env['hr.employee'].browse(updated_values['employee_id']).user_id
            if employee_user:
                res.append((employee_user.partner_id.id, subtype_ids, False))
        return res

    def activity_update(self):
        reports_requiring_feedback = self.env['hr.expense.sheet']
        reports_activity_unlink = self.env['hr.expense.sheet']
        for expense_report in self:
            if expense_report.state == 'submit':
                expense_report.activity_schedule(
                    'hr_expense.mail_act_expense_approval',
                    user_id=expense_report.sudo()._get_responsible_for_approval().id or self.env.user.id)
            elif expense_report.state == 'approve':
                reports_requiring_feedback |= expense_report
            elif expense_report.state in {'draft', 'cancel'}:
                reports_activity_unlink |= expense_report
        if reports_requiring_feedback:
            reports_requiring_feedback.activity_feedback(['hr_expense.mail_act_expense_approval'])
        if reports_activity_unlink:
            reports_activity_unlink.activity_unlink(['hr_expense.mail_act_expense_approval'])

    # --------------------------------------------
    # Actions
    # --------------------------------------------

    def action_submit_sheet(self):
        self._do_submit()

    def action_approve_expense_sheets(self):
        self._check_can_approve()
        self._validate_analytic_distribution()
        duplicates = self.expense_line_ids.duplicate_expense_ids.filtered(lambda exp: exp.state in {'approved', 'done'})
        if duplicates:
            action = self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_approve_duplicate_action')
            action['context'] = {'default_sheet_ids': self.ids, 'default_expense_ids': duplicates.ids}
            return action
        self._do_approve()

    def action_refuse_expense_sheets(self):
        self._check_can_refuse()
        return self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_refuse_wizard_action')

    def action_sheet_move_post(self):
        # When a move has been deleted
        self.filtered(lambda sheet: not sheet.account_move_ids).with_prefetch()._do_create_moves()
        self.account_move_ids.action_post()

    def action_reset_expense_sheets(self):
        self.filtered(lambda sheet: sheet.state not in {'draft', 'submit'})._check_can_reset_approval()
        self._do_reverse_moves()
        self._do_reset_approval()
        self.account_move_ids = [Command.clear()]

    def action_register_payment(self):
        ''' Open the account.payment.register wizard to pay the selected journal entries.
        There can be more than one bank_account_id in the expense sheet when registering payment for multiple expenses.
        The default_partner_bank_id is set only if there is one available, if more than one the field is left empty.
        :return: An action opening the account.payment.register wizard.
        '''
        return self.account_move_ids.with_context(default_partner_bank_id=(
            self.employee_id.sudo().bank_account_id.id if len(self.employee_id.sudo().bank_account_id.ids) <= 1 else None
        )).action_register_payment()

    def action_open_expense_view(self):
        self.ensure_one()
        if self.nb_expense == 1:
            return {
                'type': 'ir.actions.act_window',
                'view_mode': 'form',
                'res_model': 'hr.expense',
                'res_id': self.expense_line_ids.id,
            }
        return {
            'name': _('Expenses'),
            'type': 'ir.actions.act_window',
            'view_mode': 'list,form',
            'views': [[False, "list"], [False, "form"]],
            'res_model': 'hr.expense',
            'domain': [('id', 'in', self.expense_line_ids.ids)],
        }

    def action_open_account_moves(self):
        self.ensure_one()
        if self.payment_mode == 'own_account':
            res_model = 'account.move'
            record_ids = self.account_move_ids
        else:
            res_model = 'account.payment'
            record_ids = self.account_move_ids.origin_payment_id

        action = {'type': 'ir.actions.act_window', 'res_model': res_model}
        if len(self.account_move_ids) == 1:
            action.update({
                'name': record_ids.name,
                'view_mode': 'form',
                'res_id': record_ids.id,
                'views': [(False, 'form')],
            })
        else:
            action.update({
                'name': _("Journal entries"),
                'view_mode': 'list',
                'domain': [('id', 'in', record_ids.ids)],
                'views': [(False, 'list'), (False, 'form')],
            })
        return action

    # --------------------------------------------
    # Business
    # --------------------------------------------

    def set_to_paid(self):
        # hook used in other modules to bypass payment registration
        self.write({'state': 'done'})

    def set_to_posted(self):
        # hook used in other modules to bypass move creation
        self.write({'state': 'post'})

    def _check_can_approve(self):
        if not all(self.mapped('can_approve')):
            reasons = _("You cannot approve:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
            raise UserError(reasons)

    def _check_can_refuse(self):
        if not all(self.mapped('can_approve')):
            reasons = _("You cannot refuse:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
            raise UserError(reasons)

    def _check_can_reset_approval(self):
        if not all(self.mapped('can_reset')):
            raise UserError(_("Only HR Officers or the concerned employee can reset to draft."))

    def _check_can_create_move(self):
        if any(not sheet.expense_line_ids for sheet in self):
            raise UserError(_("You cannot create accounting entries for an expense report without expenses."))

        if any(sheet.state != 'submit' for sheet in self):
            raise UserError(_("You can only generate an accounting entry for approved expense(s)."))

        if any(not sheet.journal_id for sheet in self):
            raise UserError(_("Specify expense journal to generate accounting entries."))

        if False in self.mapped('payment_mode'):
            raise UserError(_(
                "Please specify if the expenses for this report were paid by the company, or the employee"
            ))

        missing_email_employees = self.filtered(lambda sheet: not sheet.employee_id.work_email).employee_id
        if missing_email_employees:
            action = self.env['ir.actions.actions']._for_xml_id('hr.open_view_employee_list_my')
            action['domain'] = [('id', 'in', missing_email_employees.ids)]
            raise RedirectWarning(_("The work email of some employees is missing. Please add it on the employee form"), action, _("Show missing work email employees"))

    def _do_submit(self):
        self.approval_state = 'submit'
        self.sudo().activity_update()

    def _do_approve(self):
        sheets_to_approve = self.filtered(lambda s: s.state in {'submit', 'draft'})
        sheets_to_approve._check_can_create_move()
        sheets_to_approve._do_create_moves()
        for sheet in sheets_to_approve:
            sheet.write({
                'approval_state': 'approve',
                'user_id': sheet.user_id.id or self.env.user.id,
                'approval_date': fields.Date.context_today(sheet),
            })
        self.activity_update()

    def _do_reset_approval(self):
        self.sudo().write({'approval_state': False, 'approval_date': False, 'accounting_date': False})
        self.activity_update()

    def _do_refuse(self, reason):
        # Sudoed as approvers may not be accountants
        draft_moves_sudo = self.sudo().account_move_ids.filtered(lambda move: move.state == 'draft')
        if self.sudo().account_move_ids - draft_moves_sudo:
            raise UserError(_("You cannot cancel an expense sheet linked to a posted journal entry"))

        if draft_moves_sudo:
            draft_moves_sudo.unlink()  # Else we have lingering moves

        self.approval_state = 'cancel'
        subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
        for sheet in self:
            sheet.message_post_with_source(
                'hr_expense.hr_expense_template_refuse_reason',
                subtype_id=subtype_id,
                render_values={'reason': reason, 'name': sheet.name},
            )
        self.activity_update()

    def _do_create_moves(self):
        """
        Creation of the account moves for the expenses report. Sudo-ed as they are created in draft and the manager may not have
        the accounting rights (and there is no reason to give them those rights).
        There are two main flows at play:
            - Expense paid by the company -> Create an account payment (we only "log" the already paid expense so it can be reconciled)
            - Expense paid by he employee's own account -> As it should be reimbursed to them, it creates a vendor bill.
        """
        self = self.with_context(clean_context(self.env.context))  # remove default_*
        own_account_sheets = self.filtered(lambda sheet: sheet.payment_mode == 'own_account')
        company_account_sheets = self - own_account_sheets

        for sheet in own_account_sheets:
            sheet.accounting_date = sheet.accounting_date or sheet._calculate_default_accounting_date()
        moves_sudo = self.env['account.move'].sudo().create([sheet._prepare_bills_vals() for sheet in own_account_sheets])
        for move_sudo in moves_sudo:
            move_sudo._message_set_main_attachment_id(move_sudo.attachment_ids, force=True, filter_xml=False)
        if company_account_sheets:
            move_vals_list, payment_vals_list = zip(*[
                expense._prepare_payments_vals()
                for expense in company_account_sheets.expense_line_ids
            ])
            payments_sudo = self.env['account.payment'].sudo().create(payment_vals_list)
            moves_sudo = self.env['account.move'].sudo().create(move_vals_list)
            for payment, move in zip(payments_sudo, moves_sudo):
                payment.write({'move_id': move.id, 'state': 'in_process'})
                move.origin_payment_id = payment
            moves_sudo |= payments_sudo.move_id

        # returning the move with the super user flag set back as it was at the origin of the call
        return moves_sudo.sudo(self.env.su)

    def _do_reverse_moves(self):
        self = self.with_context(clean_context(self.env.context))
        moves = self.account_move_ids
        draft_moves = moves.filtered(lambda m: m.state == 'draft')
        non_draft_moves = moves - draft_moves
        non_draft_moves._reverse_moves(
            default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False} for move in non_draft_moves],
            cancel=True
        )
        draft_moves.unlink()

    def _calculate_default_accounting_date(self):
        """
        Calculate the default accounting date for the expenses paid by employees
        """
        self.ensure_one()
        today = fields.Date.context_today(self)
        start_month = fields.Date.start_of(today, "month")
        end_month = fields.Date.end_of(today, "month")
        most_recent_expense = max(self.expense_line_ids.filtered(lambda exp: exp.date).mapped('date'), default=today)

        if most_recent_expense > end_month:
            return most_recent_expense

        if most_recent_expense >= start_month:
            return today

        lock_date = self.company_id._get_user_fiscal_lock_date(self.journal_id)

        return min(
            max(
                fields.Date.end_of(most_recent_expense, "month"),
                fields.Date.end_of(fields.Date.add(lock_date, months=1), "month")
            ),
            today
        )

    def _prepare_bills_vals(self):
        self.ensure_one()

        return {
            **self._prepare_move_vals(),
            'journal_id': self.journal_id.id,
            'ref': self.name,
            'move_type': 'in_invoice',
            'partner_id': self.employee_id.sudo().work_contact_id.id,
            'currency_id': self.currency_id.id,
            'line_ids': [Command.create(expense._prepare_move_lines_vals()) for expense in self.expense_line_ids],
            'attachment_ids': [
                Command.create(attachment.copy_data({'res_model': 'account.move', 'res_id': False, 'raw': attachment.raw})[0])
                for attachment in self.expense_line_ids.message_main_attachment_id
            ],
        }

    def _prepare_move_vals(self):
        self.ensure_one()
        to_return = {
            # force the name to the default value, to avoid an eventual 'default_name' in the context
            # to set it to '' which cause no number to be given to the account.move when posted.
            'name': '/',
            'expense_sheet_id': self.id,
        }

        today = fields.Date.context_today(self)
        most_recent_expense = max(self.expense_line_ids.filtered(lambda exp: exp.date).mapped('date'), default=today)

        if self.payment_mode == 'company_account':
            to_return['date'] = most_recent_expense
        else:
            to_return['invoice_date'] = self.accounting_date

        return to_return

    def _validate_analytic_distribution(self):
        for line in self.expense_line_ids:
            line._validate_distribution(account=line.account_id.id, product=line.product_id.id, business_domain='expense', company_id=line.company_id.id)

    def _get_responsible_for_approval(self):
        if self.user_id:
            return self.user_id
        if self.employee_id.parent_id.user_id:
            return self.employee_id.parent_id.user_id
        if self.employee_id.department_id.manager_id.user_id:
            return self.employee_id.department_id.manager_id.user_id
        return self.env['res.users']

    def _get_expense_account_destination(self):
        self.ensure_one()
        if self.payment_mode == 'company_account':
            journal = self.payment_method_line_id.journal_id
            account_dest = (
                self.payment_method_line_id.payment_account_id
                or journal.company_id.expense_outstanding_account_id
            )
            if not account_dest:
                raise UserError(_(
                    "The payment method %(method)s needs an account, "
                    "or a default outstanding account must be defined in the settings.",
                    method=self.payment_method_line_id.display_name,
                ))
        else:
            if not self.employee_id.sudo().work_contact_id:
                raise UserError(_("No work contact found for the employee %s, please configure one.", self.employee_id.name))
            partner = self.employee_id.sudo().work_contact_id.with_company(self.company_id)
            account_dest = partner.property_account_payable_id or partner.parent_id.property_account_payable_id
        return account_dest.id