hr_contract.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import threading
  4. from datetime import date
  5. from dateutil.relativedelta import relativedelta
  6. from odoo import api, fields, models, _
  7. from odoo.exceptions import ValidationError
  8. from odoo.osv import expression
  9. import logging
  10. _logger = logging.getLogger(__name__)
  11. class Contract(models.Model):
  12. _name = 'hr.contract'
  13. _description = 'Contract'
  14. _inherit = ['mail.thread', 'mail.activity.mixin']
  15. _mail_post_access = 'read'
  16. name = fields.Char('Contract Reference', required=True)
  17. active = fields.Boolean(default=True)
  18. structure_type_id = fields.Many2one('hr.payroll.structure.type', string="Salary Structure Type")
  19. employee_id = fields.Many2one('hr.employee', string='Employee', tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
  20. department_id = fields.Many2one('hr.department', compute='_compute_employee_contract', store=True, readonly=False,
  21. domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", string="Department")
  22. job_id = fields.Many2one('hr.job', compute='_compute_employee_contract', store=True, readonly=False,
  23. domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", string='Job Position')
  24. date_start = fields.Date('Start Date', required=True, default=fields.Date.today, tracking=True, index=True)
  25. date_end = fields.Date('End Date', tracking=True,
  26. help="End date of the contract (if it's a fixed-term contract).")
  27. trial_date_end = fields.Date('End of Trial Period',
  28. help="End date of the trial period (if there is one).")
  29. resource_calendar_id = fields.Many2one(
  30. 'resource.calendar', 'Working Schedule', compute='_compute_employee_contract', store=True, readonly=False,
  31. default=lambda self: self.env.company.resource_calendar_id.id, copy=False, index=True,
  32. domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
  33. wage = fields.Monetary('Wage', required=True, tracking=True, help="Employee's monthly gross wage.")
  34. contract_wage = fields.Monetary('Contract Wage', compute='_compute_contract_wage')
  35. notes = fields.Html('Notes')
  36. state = fields.Selection([
  37. ('draft', 'New'),
  38. ('open', 'Running'),
  39. ('close', 'Expired'),
  40. ('cancel', 'Cancelled')
  41. ], string='Status', group_expand='_expand_states', copy=False,
  42. tracking=True, help='Status of the contract', default='draft')
  43. company_id = fields.Many2one('res.company', compute='_compute_employee_contract', store=True, readonly=False,
  44. default=lambda self: self.env.company, required=True)
  45. company_country_id = fields.Many2one('res.country', string="Company country", related='company_id.country_id', readonly=True)
  46. country_code = fields.Char(related='company_country_id.code', depends=['company_country_id'], readonly=True)
  47. contract_type_id = fields.Many2one('hr.contract.type', "Contract Type")
  48. """
  49. kanban_state:
  50. * draft + green = "Incoming" state (will be set as Open once the contract has started)
  51. * open + red = "Pending" state (will be set as Closed once the contract has ended)
  52. * red = Shows a warning on the employees kanban view
  53. """
  54. kanban_state = fields.Selection([
  55. ('normal', 'Grey'),
  56. ('done', 'Green'),
  57. ('blocked', 'Red')
  58. ], string='Kanban State', default='normal', tracking=True, copy=False)
  59. currency_id = fields.Many2one(string="Currency", related='company_id.currency_id', readonly=True)
  60. permit_no = fields.Char('Work Permit No', related="employee_id.permit_no", readonly=False)
  61. visa_no = fields.Char('Visa No', related="employee_id.visa_no", readonly=False)
  62. visa_expire = fields.Date('Visa Expire Date', related="employee_id.visa_expire", readonly=False)
  63. def _get_hr_responsible_domain(self):
  64. return "[('share', '=', False), ('company_ids', 'in', company_id), ('groups_id', 'in', %s)]" % self.env.ref('hr.group_hr_user').id
  65. hr_responsible_id = fields.Many2one('res.users', 'HR Responsible', tracking=True,
  66. help='Person responsible for validating the employee\'s contracts.', domain=_get_hr_responsible_domain)
  67. calendar_mismatch = fields.Boolean(compute='_compute_calendar_mismatch', compute_sudo=True)
  68. first_contract_date = fields.Date(related='employee_id.first_contract_date')
  69. @api.depends('employee_id.resource_calendar_id', 'resource_calendar_id')
  70. def _compute_calendar_mismatch(self):
  71. for contract in self:
  72. contract.calendar_mismatch = contract.resource_calendar_id != contract.employee_id.resource_calendar_id
  73. def _expand_states(self, states, domain, order):
  74. return [key for key, val in type(self).state.selection]
  75. @api.depends('employee_id')
  76. def _compute_employee_contract(self):
  77. for contract in self.filtered('employee_id'):
  78. contract.job_id = contract.employee_id.job_id
  79. contract.department_id = contract.employee_id.department_id
  80. contract.resource_calendar_id = contract.employee_id.resource_calendar_id
  81. contract.company_id = contract.employee_id.company_id
  82. @api.onchange('company_id')
  83. def _onchange_company_id(self):
  84. if self.company_id:
  85. structure_types = self.env['hr.payroll.structure.type'].search([
  86. '|',
  87. ('country_id', '=', self.company_id.country_id.id),
  88. ('country_id', '=', False)])
  89. if structure_types:
  90. self.structure_type_id = structure_types[0]
  91. elif self.structure_type_id not in structure_types:
  92. self.structure_type_id = False
  93. @api.onchange('structure_type_id')
  94. def _onchange_structure_type_id(self):
  95. default_calendar = self.structure_type_id.default_resource_calendar_id
  96. if default_calendar and default_calendar.company_id == self.company_id:
  97. self.resource_calendar_id = self.structure_type_id.default_resource_calendar_id
  98. @api.constrains('employee_id', 'state', 'kanban_state', 'date_start', 'date_end')
  99. def _check_current_contract(self):
  100. """ Two contracts in state [incoming | open | close] cannot overlap """
  101. for contract in self.filtered(lambda c: (c.state not in ['draft', 'cancel'] or c.state == 'draft' and c.kanban_state == 'done') and c.employee_id):
  102. domain = [
  103. ('id', '!=', contract.id),
  104. ('employee_id', '=', contract.employee_id.id),
  105. ('company_id', '=', contract.company_id.id),
  106. '|',
  107. ('state', 'in', ['open', 'close']),
  108. '&',
  109. ('state', '=', 'draft'),
  110. ('kanban_state', '=', 'done') # replaces incoming
  111. ]
  112. if not contract.date_end:
  113. start_domain = []
  114. end_domain = ['|', ('date_end', '>=', contract.date_start), ('date_end', '=', False)]
  115. else:
  116. start_domain = [('date_start', '<=', contract.date_end)]
  117. end_domain = ['|', ('date_end', '>', contract.date_start), ('date_end', '=', False)]
  118. domain = expression.AND([domain, start_domain, end_domain])
  119. if self.search_count(domain):
  120. raise ValidationError(
  121. _(
  122. 'An employee can only have one contract at the same time. (Excluding Draft and Cancelled contracts).\n\nEmployee: %(employee_name)s',
  123. employee_name=contract.employee_id.name
  124. )
  125. )
  126. @api.constrains('date_start', 'date_end')
  127. def _check_dates(self):
  128. for contract in self:
  129. if contract.date_end and contract.date_start > contract.date_end:
  130. raise ValidationError(_(
  131. 'Contract %(contract)s: start date (%(start)s) must be earlier than contract end date (%(end)s).',
  132. contract=contract.name, start=contract.date_start, end=contract.date_end,
  133. ))
  134. @api.model
  135. def update_state(self):
  136. from_cron = 'from_cron' in self.env.context
  137. contracts = self.search([
  138. ('state', '=', 'open'), ('kanban_state', '!=', 'blocked'),
  139. '|',
  140. '&',
  141. ('date_end', '<=', fields.Date.to_string(date.today() + relativedelta(days=7))),
  142. ('date_end', '>=', fields.Date.to_string(date.today() + relativedelta(days=1))),
  143. '&',
  144. ('visa_expire', '<=', fields.Date.to_string(date.today() + relativedelta(days=60))),
  145. ('visa_expire', '>=', fields.Date.to_string(date.today() + relativedelta(days=1))),
  146. ])
  147. for contract in contracts:
  148. contract.with_context(mail_activity_quick_update=True).activity_schedule(
  149. 'mail.mail_activity_data_todo', contract.date_end,
  150. _("The contract of %s is about to expire.", contract.employee_id.name),
  151. user_id=contract.hr_responsible_id.id or self.env.uid)
  152. if contracts:
  153. contracts._safe_write_for_cron({'kanban_state': 'blocked'}, from_cron)
  154. contracts_to_close = self.search([
  155. ('state', '=', 'open'),
  156. '|',
  157. ('date_end', '<=', fields.Date.to_string(date.today())),
  158. ('visa_expire', '<=', fields.Date.to_string(date.today())),
  159. ])
  160. if contracts_to_close:
  161. contracts_to_close._safe_write_for_cron({'state': 'close'}, from_cron)
  162. contracts_to_open = self.search([('state', '=', 'draft'), ('kanban_state', '=', 'done'), ('date_start', '<=', fields.Date.to_string(date.today())),])
  163. if contracts_to_open:
  164. contracts_to_open._safe_write_for_cron({'state': 'open'}, from_cron)
  165. contract_ids = self.search([('date_end', '=', False), ('state', '=', 'close'), ('employee_id', '!=', False)])
  166. # Ensure all closed contract followed by a new contract have a end date.
  167. # If closed contract has no closed date, the work entries will be generated for an unlimited period.
  168. for contract in contract_ids:
  169. next_contract = self.search([
  170. ('employee_id', '=', contract.employee_id.id),
  171. ('state', 'not in', ['cancel', 'draft']),
  172. ('date_start', '>', contract.date_start)
  173. ], order="date_start asc", limit=1)
  174. if next_contract:
  175. contract._safe_write_for_cron({'date_end': next_contract.date_start - relativedelta(days=1)}, from_cron)
  176. continue
  177. next_contract = self.search([
  178. ('employee_id', '=', contract.employee_id.id),
  179. ('date_start', '>', contract.date_start)
  180. ], order="date_start asc", limit=1)
  181. if next_contract:
  182. contract._safe_write_for_cron({'date_end': next_contract.date_start - relativedelta(days=1)}, from_cron)
  183. return True
  184. def _safe_write_for_cron(self, vals, from_cron=False):
  185. if from_cron:
  186. auto_commit = not getattr(threading.current_thread(), 'testing', False)
  187. for contract in self:
  188. try:
  189. with self.env.cr.savepoint():
  190. contract.write(vals)
  191. except ValidationError as e:
  192. _logger.warning(e)
  193. else:
  194. if auto_commit:
  195. self.env.cr.commit()
  196. else:
  197. self.write(vals)
  198. def _assign_open_contract(self):
  199. for contract in self:
  200. contract.employee_id.sudo().write({'contract_id': contract.id})
  201. @api.depends('wage')
  202. def _compute_contract_wage(self):
  203. for contract in self:
  204. contract.contract_wage = contract._get_contract_wage()
  205. def _get_contract_wage(self):
  206. if not self:
  207. return 0
  208. self.ensure_one()
  209. return self[self._get_contract_wage_field()]
  210. def _get_contract_wage_field(self):
  211. return 'wage'
  212. def write(self, vals):
  213. old_state = {c.id: c.state for c in self}
  214. res = super(Contract, self).write(vals)
  215. new_state = {c.id: c.state for c in self}
  216. if vals.get('state') == 'open':
  217. self._assign_open_contract()
  218. today = fields.Date.today()
  219. for contract in self:
  220. if contract == contract.employee_id.contract_id \
  221. and old_state[contract.id] == 'open' \
  222. and new_state[contract.id] != 'open':
  223. running_contract = self.env['hr.contract'].search([
  224. ('employee_id', '=', contract.employee_id.id),
  225. ('company_id', '=', contract.company_id.id),
  226. ('state', '=', 'open'),
  227. ]).filtered(lambda c: c.date_start <= today and (not c.date_end or c.date_end >= today))
  228. if running_contract:
  229. contract.employee_id.contract_id = running_contract[0]
  230. if vals.get('state') == 'close':
  231. for contract in self.filtered(lambda c: not c.date_end):
  232. contract.date_end = max(date.today(), contract.date_start)
  233. date_end = vals.get('date_end')
  234. if self.env.context.get('close_contract', True) and date_end and fields.Date.from_string(date_end) < fields.Date.context_today(self):
  235. for contract in self.filtered(lambda c: c.state == 'open'):
  236. contract.state = 'close'
  237. calendar = vals.get('resource_calendar_id')
  238. if calendar:
  239. self.filtered(lambda c: c.state == 'open' or (c.state == 'draft' and c.kanban_state == 'done')).mapped('employee_id').write({'resource_calendar_id': calendar})
  240. if 'state' in vals and 'kanban_state' not in vals:
  241. self.write({'kanban_state': 'normal'})
  242. return res
  243. @api.model_create_multi
  244. def create(self, vals_list):
  245. contracts = super().create(vals_list)
  246. contracts.filtered(lambda c: c.state == 'open')._assign_open_contract()
  247. open_contracts = contracts.filtered(lambda c: c.state == 'open' or c.state == 'draft' and c.kanban_state == 'done')
  248. # sync contract calendar -> calendar employee
  249. for contract in open_contracts.filtered(lambda c: c.employee_id and c.resource_calendar_id):
  250. contract.employee_id.resource_calendar_id = contract.resource_calendar_id
  251. return contracts
  252. def _track_subtype(self, init_values):
  253. self.ensure_one()
  254. if 'state' in init_values and self.state == 'open' and 'kanban_state' in init_values and self.kanban_state == 'blocked':
  255. return self.env.ref('hr_contract.mt_contract_pending')
  256. elif 'state' in init_values and self.state == 'close':
  257. return self.env.ref('hr_contract.mt_contract_close')
  258. return super(Contract, self)._track_subtype(init_values)
  259. def action_open_contract_form(self):
  260. self.ensure_one()
  261. action = self.env['ir.actions.actions']._for_xml_id('hr_contract.action_hr_contract')
  262. action.update({
  263. 'view_mode': 'form',
  264. 'view_id': self.env.ref('hr_contract.hr_contract_view_form').id,
  265. 'views': [(self.env.ref('hr_contract.hr_contract_view_form').id, 'form')],
  266. 'res_id': self.id,
  267. })
  268. return action