diff --git a/hr_duty_planner/.gitignore b/hr_duty_planner/.gitignore new file mode 100644 index 00000000000..68abf6f41b6 --- /dev/null +++ b/hr_duty_planner/.gitignore @@ -0,0 +1 @@ +models/__pycache__ \ No newline at end of file diff --git a/hr_duty_planner/README.rst b/hr_duty_planner/README.rst new file mode 100644 index 00000000000..f90d1297c46 --- /dev/null +++ b/hr_duty_planner/README.rst @@ -0,0 +1,143 @@ +================= +HR - Duty Planner +================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr-lightgray.png?logo=github + :target: https://github.com/OCA/hr/tree/12.0/hr_duty_planner + :alt: OCA/hr +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-12-0/hr-12-0-hr_duty_planner + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/116/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +These module manages the Service Planner. + +_TODO_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Steps to follow to use Service Planner** + +**Set up** + + **Rules** + * Rules and Rules Fields can not be modified by Users. Read what they do and how to use fields. + + **Select Rules → Profile** + * Create a Profile + * Add rule fields with values + * Add profile to Employees: Employees → get one → fill Service Profile + * Add profile to Vehicle: Fleet → get one → fill Service Profile + * Add profile to Equipment: Maintenance → Equipments → get one → fill Service Profile + + **Employee** + * Create desired Skills: get an Employee → Skill tab → add row → create + + **Maintenance → Configuration → Categories** + * Create desired Categories + + **Fleet** + * Create desired Types: get a Vehicle → Vehicle Type → create + + **Select Template → Container Service** + * Create a list of Container Services to collect single services + + **Select Template → Service Template** + * Create a Template Service + + * Name, Container Service and Duration are mandatory + * Check if it is an Off Duty Service (normally used to track some informations, ie. rest shifts) + * Add requested Features to be sure to not forget to assign Resources. + (Minimum quantity if zero will not be considered) + * Optional, associate a Next Service: this will be automatically generated starting form the end of this one. + * Optional, set resources of parent service locked by the next service + + **Select Template → Service Template → your template** + * Clik on *Generate Services* button + * Template is preselected + * Select a Container + * Select a start date-time + * Select a stop date-time + * Set repetition interval (hours). If interval is inferior to the template duration, this one will be used + * Set available days of week + * Set to use work Calendar + * Set to use holiday + * Click on *Generate* button + + **Planning** + * Select Services → List view* + * Select a Service + * Fill resources (eg. Employee, Equipments, Vehicles) + * Check requirements (Template requestested Resources) fulfillment + * Calendar/Timeline: overview / move services + +Changelog +========= + +12.0.1.0.0 (2020-05-01) +~~~~~~~~~~~~~~~~~~~~~~~ + +* [INI] Initial development + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Stefano Consolaro +* Marcelo Frare Associazione PNLUG - Gruppo Odoo + +Contributors +~~~~~~~~~~~~ + +* Stefano Consolaro +* Marcelo Frare + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/hr `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_duty_planner/TODO.rst b/hr_duty_planner/TODO.rst new file mode 100644 index 00000000000..da746253d7f --- /dev/null +++ b/hr_duty_planner/TODO.rst @@ -0,0 +1,52 @@ +ToDo list + + +**Models** + * service template + * DEV management of working/holiday calendar on generation + * service allocate + * DEV activate _compute_emply_name method to update all employees name list + * service rule + * DEV complete rule method management + * DEV management of errors: log/popup + * calendar + * DEV check module + +**Views** + * service template form + * FIX custom css loading + * FIX on deploy set off_duty to readonly + * FIX layout + * service allocate tree + * FIX check rule button position + * service allocate form + * DEV filter resources on availability + * service allocate calendar + * DEV text format + * DEV check/alert template expected fulfillment + * FIX employee name display (newline separated) + * FIX element dedicated color (web_calendar) + * service allocate timeline + * DEV try add another level of group (ie. locality) + * DEV check UIX + * DEV check/alert template expected fulfillment + * FIX element dedicated color (web_timeline) + * service rule + * FIX on deploy lock edit option + * FIX optimize double_assign method + * service profile + * FIX filter available fields for selected rule !!! + * DEV create check for rule-field assignment (association, mandatory) + * reporting + * DEV basic report + +**Functional** + * service + * DEV define how to manage real start/end time + +**Security** + * FIX model authorizations + + +**Readme** +oca-gen-addon-readme --repo-name hr --branch 12.0 --addon-dir ~/odoo-dev/odoo12/OCA/addons-custom-sp/hr_duty_planner/ diff --git a/hr_duty_planner/__init__.py b/hr_duty_planner/__init__.py new file mode 100644 index 00000000000..7c10c8dbe8c --- /dev/null +++ b/hr_duty_planner/__init__.py @@ -0,0 +1,5 @@ +from . import ( + models, + wizards, + reports, +) diff --git a/hr_duty_planner/__manifest__.py b/hr_duty_planner/__manifest__.py new file mode 100644 index 00000000000..32be700c96d --- /dev/null +++ b/hr_duty_planner/__manifest__.py @@ -0,0 +1,55 @@ +# Copyright 2019 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "HR - Duty Planner", + "summary": "Management of duty planning.", + "version": "12.0.1.0.0", + + "author": "Stefano Consolaro , " + "Marcelo Frare " + "Associazione PNLUG - Gruppo Odoo, " + "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/hr/", + "license": "AGPL-3", + + "category": "Human Resources", + + "depends": [ + 'fleet', + 'maintenance', + 'hr', + 'product', + # OCA modules + 'web_timeline', + 'web_widget_color', + 'hr_skill', + # 'hr_employee_partner_external', + 'fleet_vehicle_category', + 'report_xlsx', + ], + "data": [ + 'data/data_service_rule.xml', + 'data/data_service_rulefield.xml', + 'data/demo.xml', + 'security/ir.model.access.csv', + 'wizards/service_generate.xml', + 'wizards/service_duplicate.xml', + 'views/service_menu.xml', + 'views/service_allocate_view.xml', + 'views/service_rule_view.xml', + 'views/service_profile_view.xml', + 'views/service_aggregator_view.xml', + 'views/service_container_view.xml', + 'views/service_expected_view.xml', + 'views/service_template_view.xml', + 'views/service_utility_view.xml', + 'views/employee_profile.xml', + 'views/vehicle_profile.xml', + 'views/equipment_profile.xml', + 'reports/xlsx_allocated_service.xml', + ], + "css": [ + 'static/src/css/hr_duty_planner.css', + ], +} diff --git a/hr_duty_planner/data/data_service_rule.xml b/hr_duty_planner/data/data_service_rule.xml new file mode 100644 index 00000000000..0067dcc878d --- /dev/null +++ b/hr_duty_planner/data/data_service_rule.xml @@ -0,0 +1,43 @@ + + + + + + + hour_active_day + Hours active per day + + + hour_active_week + Hours active per week + + + hour_active_month + Hours active per month + + + hour_rest_day + Rest hours per day + + + hour_rest_week + Rest hours per week + + + hour_rest_month + Rest hours per month + + + end_of_shift_rest + End of shift rest + + + end_of_night_shift_rest + End of night shift rest + + + + diff --git a/hr_duty_planner/data/data_service_rulefield.xml b/hr_duty_planner/data/data_service_rulefield.xml new file mode 100644 index 00000000000..1a22476aa50 --- /dev/null +++ b/hr_duty_planner/data/data_service_rulefield.xml @@ -0,0 +1,52 @@ + + + + + + + + + h_max + Maximum work hours per day + 12 + True + + + + + h_max + Maximum work hours per week + 36 + True + + + + + h_max + Maximum work hours per month + 144 + True + + + + + h_min + Minimum rest hours per week + 24 + True + + + + + is_request + Reguire a rest shift after a work shift + True + True + + + + + diff --git a/hr_duty_planner/data/demo.xml b/hr_duty_planner/data/demo.xml new file mode 100644 index 00000000000..76a1ede6ea0 --- /dev/null +++ b/hr_duty_planner/data/demo.xml @@ -0,0 +1,547 @@ + + + + + + + + + 1 + 12 + + + + 1 + 6 + + + + 2 + 48 + + + + 1 + 148 + + + + 1 + 120 + + + + 3 + 24 + + + + + Nurse professional + + + + Nurse trainee + + + + Driver professional + + + + Controller + + + + Vehicle/Equipment check + + + + + + AA Fabrizio Bianchi + + + + AA Roberto Testa + + + + AA Bianca Monti + + + + AA Moreno Rossi + + + + AA Luca Verdi + + + + AA Dina Lampa + + + + AA Dario Lampa + + + + AA Dino Como + + + + + + Nurse + + + Driver type 'B' + + + Driver type 'C' + + + Pilot + + + Controller + + + + + + 2 + + + + 1 + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ambulance + + + Doctor's car + + + Pickup + + + + + + 1 + 1 + + + + 1 + 1 + + + + + 1 + + PN-2020-LUG + + + + 1 + + OD-2020-OO + + + + + + Electrical continuity + + + Pulmonary support + + + Heart support + + + Hardware + + + + + + + + + + + + + + + + Generator set 1 + + + + + Oxygen tank 1 + + + + + Defibrillator 1 + + + + + Generator set 2 + + + + + Oxygen tank 2 + + + + + Defibrillator 2 + + + + + + + Emergency support + Non-stop emergency + + + Call center + Non-stop call center + + + Off Duty + Rest/Maintenance shifts + + + + + Call center diurnal + + + 16 + 6 + #6AFF6A + + + + Call center night + + + 16 + 6 + #6A6AFF + + + + Rest + + 8 + 6 + True + #BEBEBE + + + + Emergency + + 8 + 6 + + + + + #FF6A6A + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + + + + planned + + + + + + + planned + + + + + + + planned + + + + + + + planned + + + + + + + planned + + + + + + + planned + + + + + + + planned + + + + + + diff --git a/hr_duty_planner/models/__init__.py b/hr_duty_planner/models/__init__.py new file mode 100644 index 00000000000..d2b2a4aa09a --- /dev/null +++ b/hr_duty_planner/models/__init__.py @@ -0,0 +1,6 @@ +from . import service_container, service_template, service_allocate, service_state, \ + expected_equipment, expected_vehicle, expected_skill, \ + service_aggregator, \ + service_rule, service_rule_field, service_profile, service_profile_parameter, \ + service_utility, \ + employee_profile, vehicle_profile, equipment_profile diff --git a/hr_duty_planner/models/employee_profile.py b/hr_duty_planner/models/employee_profile.py new file mode 100644 index 00000000000..897ceeab0b4 --- /dev/null +++ b/hr_duty_planner/models/employee_profile.py @@ -0,0 +1,17 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class EmployeeProfile(models.Model): + """ + Add Profile reference to Employee + """ + + _inherit = 'hr.employee' + + # Profile reference + profile_id = fields.Many2one('service.profile', + 'Service profile', + help='Employee service profile') diff --git a/hr_duty_planner/models/equipment_profile.py b/hr_duty_planner/models/equipment_profile.py new file mode 100644 index 00000000000..cf7ac782326 --- /dev/null +++ b/hr_duty_planner/models/equipment_profile.py @@ -0,0 +1,17 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class EquipmentProfile(models.Model): + """ + Add Profile reference to Equipment + """ + + _inherit = 'maintenance.equipment' + + # Profile reference + profile_id = fields.Many2one('service.profile', + 'Service profile', + help='Equipment service profile') diff --git a/hr_duty_planner/models/expected_equipment.py b/hr_duty_planner/models/expected_equipment.py new file mode 100644 index 00000000000..e4d51b05fb1 --- /dev/null +++ b/hr_duty_planner/models/expected_equipment.py @@ -0,0 +1,27 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ExpectedEquipmentCategory(models.Model): + """ + Equipment category list expected on a service + """ + + # model + _name = 'expected.eqpmnt_cat' + _description = 'Service expected equipment' + + # fields + # minimum quantity + min_qty = fields.Integer('Minimum quantity', required=True, default=1) + # maximum quantity: 0 for no limit + max_qty = fields.Integer('Maximum quantity', help="Value 0 means no limit") + # equipment category required + eqp_cat_id = fields.Many2one('maintenance.equipment.category', + string='Equipment category', + required=True) + + # define record name to display in form view + _rec_name = 'eqp_cat_id' diff --git a/hr_duty_planner/models/expected_skill.py b/hr_duty_planner/models/expected_skill.py new file mode 100644 index 00000000000..f8f7cacf96c --- /dev/null +++ b/hr_duty_planner/models/expected_skill.py @@ -0,0 +1,26 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ExpectedSkill(models.Model): + """ + Employee skills expected on a service + """ + + # model + _name = 'expected.skill' + _description = 'Service expected skills' + + # fields + # minimum quantity + min_qty = fields.Integer('Minimum quantity', required=True, default=1) + # maximum quantity: 0 for no limit + max_qty = fields.Integer('Maximum quantity', help="Value 0 means no limit") + # skill required + skill_id = fields.Many2one('hr.skill', string='Skill', + required=True) + + # define record name to display in form view + _rec_name = 'skill_id' diff --git a/hr_duty_planner/models/expected_vehicle.py b/hr_duty_planner/models/expected_vehicle.py new file mode 100644 index 00000000000..b524f8ef744 --- /dev/null +++ b/hr_duty_planner/models/expected_vehicle.py @@ -0,0 +1,28 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ExpectedVehicle(models.Model): + """ + Vehicle element expected on a service + """ + + # model + _name = 'expected.vhcl_category' + _description = 'Service expected vehicle' + + # fields + # minimum quantity + min_qty = fields.Integer('Minimum quantity', required=True, default=1) + # maximum quantity: 0 for no limit + max_qty = fields.Integer('Maximum quantity', help="Value 0 means no limit") + # vehicle + vehicle_category_id = fields.Many2one('fleet.vehicle.category', + string='Vehicle Category', + required=True, + ) + + # define record name to display in form view + _rec_name = 'vehicle_category_id' diff --git a/hr_duty_planner/models/service_aggregator.py b/hr_duty_planner/models/service_aggregator.py new file mode 100644 index 00000000000..475f030d3b6 --- /dev/null +++ b/hr_duty_planner/models/service_aggregator.py @@ -0,0 +1,24 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ServiceAggregator(models.Model): + """ + Aggregator of service templates to calculate final values + eg. amount of hours worked, number of rest shift in a month + """ + + # model + _name = 'service.aggregator' + _description = 'Aggregator of service templates' + + # fields + # name + name = fields.Char('Name') + # description + description = fields.Char('Description') + # service template to collect + service_template_ids = fields.Many2many('service.template', + string='Collected Services') diff --git a/hr_duty_planner/models/service_allocate.py b/hr_duty_planner/models/service_allocate.py new file mode 100644 index 00000000000..174e93dc8ab --- /dev/null +++ b/hr_duty_planner/models/service_allocate.py @@ -0,0 +1,563 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models, api, _ +import datetime +import json +import logging + +_logger = logging.getLogger(__name__) + + +class ServiceAllocate(models.Model): + """ + Allocated service with definition of all the template components + """ + + # model + _name = 'service.allocate' + _description = 'Allocate service' + + # fields + # template service reference + service_template_id = fields.Many2one('service.template', + string='Template service', + required=True, + ) + # off duty service + off_duty = fields.Boolean('Off Duty', + related='service_template_id.off_duty') + + # container service reference + service_container_id = fields.Many2one('service.container', + string='Container service', + required=True, + ) + # generation id (eg. to select a list of aumatic generated services) + generation_id = fields.Char('Generation ID', + help='Group services generated automatically') + # dedicated color + service_color = fields.Char('Color', + related='service_template_id.base_color') + + # assigned employee + employee_ids = fields.Many2many('hr.employee', string='Team') + # employee names + employee_names = fields.Text('Employees', compute='_compute_emply_name', store=True) + # message for skills check + employee_check = fields.Text('Skills coverage', default='Not checked', store=True) + # assigned equipment + equipment_ids = fields.Many2many('maintenance.equipment', string='Equipment') + # message for equipments check + equipment_check = fields.Text('Equipments coverage', default='Not checked', + store=True) + # assigned vehicles + vehicle_ids = fields.Many2many('fleet.vehicle', string='Vehicles') + # message for vehicles check + vehicle_check = fields.Text('Vehicles coverage', default='Not checked', store=True) + + # locality reference + locality = fields.Char('Locality') + + # scheduled start time + scheduled_start = fields.Datetime('Start scheduled', required=True) + # scheduled start time + scheduled_stop = fields.Datetime('Stop scheduled', + compute='_compute_scheduled_stop', store=True) + # effective start time + start_real = fields.Datetime('Start real') + # effective stop time + stop_real = fields.Datetime('Stop real') + + # state of the service + state = fields.Selection([('planned', 'Planned'), + ('confirmed', 'Confirmed'), + ('closed', 'Closed') + ], string='State', required=True, default='planned') + + # parent service that generate this as next service + parent_service_id = fields.Many2one('service.allocate', + 'Parent Service', + help='Service that generated this one by \ + template next option' + ) + # next service generate from this one + next_service_id = fields.Many2one('service.allocate', + 'Next Service', + help='Service generated by this one by \ + template next option' + ) + + # define record name to display in form view + _rec_name = 'id' + + @api.depends('scheduled_start') + def _compute_scheduled_stop(self): + for service in self: + if service.scheduled_start: + slot = service.service_template_id.duration + # avoid empty value of duration + slot = slot if slot > 0 else 1 + service.scheduled_stop = (service.scheduled_start + + datetime.timedelta(hours=slot)) + + return + + # main changes monitor methods + @api.multi + @api.onchange('employee_ids') + def _employee_ids_change(self): + """ + Call methods on employees changes + """ + self.check_skill_request() + + @api.multi + @api.onchange('equipment_ids') + def _equipment_ids_change(self): + """ + Call methods on equipments changes + """ + self.check_equipment_request() + + @api.multi + @api.onchange('vehicle_ids') + def _vehicle_ids_change(self): + """ + Call methods on vehicles changes + """ + self.check_vehicle_request() + + # changes dedicated methods + def _compute_emply_name(self): + """ + Update field with name list + """ + for service in self: + service.employee_names = '' + for employee in service.employee_ids: + service.employee_names += employee.name + ', \n' + return + + # utility to filter container services to template's container services + @api.onchange('service_template_id') + def _get_template_container(self): + """ + Extract list of container services associated to the template service + """ + container_services = [] + # reset value to avoid errors + self.service_container_id = [(5)] + for glob_srv in self.service_template_id.service_container_ids: + container_services.append(glob_srv.id) + + return {'domain': {'service_container_id': [('id', 'in', container_services)]}} + + # ################################################################################## + # CHECK RESOURCE REQUEST + + @api.onchange('service_template_id') + def check_skill_request(self): + """ + Check if all required skills are covered by employees + """ + for service in self: + # get requested skills by template + skill_request = service.service_template_id.exp_empl_skill_ids + # set error message + self.employee_check = ('' + if skill_request or service.off_duty + else 'Not checked' + ) + # for each request counts available employees + for request in skill_request: + available_qty = 0 + # chek requested skill in each employee + for employee in service.employee_ids: + if self.env['hr.employee.skill'].search( + [('employee_id', '=', employee.id), + ('skill_id', '=', request.skill_id.id), + ]): + available_qty += 1 + if available_qty < request.min_qty: + self.employee_check += (_('Missing %s (min %s)\n') + % (request.skill_id.name, + request.min_qty) + ) + if available_qty > request.max_qty > 0: + self.employee_check += (_('Too many %s (max %s)\n') + % (request.skill_id.name, + request.max_qty) + ) + if self.employee_check == '': + self.employee_check = 'All covered' + + self._compute_emply_name() + + return {'message': self.employee_check, + 'result': self.employee_check == 'All covered', + 'data': {} + } + + @api.onchange('service_template_id') + def check_equipment_request(self): + """ + Check if all required categories are covered by equipments + """ + for service in self: + # get requested categoryies by template + category_request = service.service_template_id.exp_eqpmt_category_ids + # set error message + self.equipment_check = ('' + if category_request or service.off_duty + else 'Not checked' + ) + # for each request counts available categories + for request in category_request: + available_qty = 0 + # chek requested categories in each equipment + for equipment in service.equipment_ids: + if self.env['maintenance.equipment'].search( + [('id', '=', equipment.id), + ('category_id', '=', request.eqp_cat_id.id), + ]): + available_qty += 1 + if available_qty < request.min_qty: + self.equipment_check += (_('Missing %s (min %s)\n') + % (request.eqp_cat_id.name, + request.min_qty) + ) + if available_qty > request.max_qty > 0: + self.equipment_check += (_('Too many %s (max %s)\n') + % (request.eqp_cat_id.name, + request.max_qty) + ) + if self.equipment_check == '': + self.equipment_check = 'All covered' + + return {'message': self.equipment_check, + 'result': self.equipment_check == 'All covered', + 'data': {} + } + + @api.onchange('service_template_id') + def check_vehicle_request(self): + """ + Check if all required type are covered by vehicles + """ + for service in self: + # get requested categoryies by template + vehicle_request = service.service_template_id.exp_vhcl_type_ids + # set error message + self.vehicle_check = ('' + if vehicle_request or service.off_duty + else 'Not checked' + ) + # for each request counts available types + for request in vehicle_request: + available_qty = 0 + # chek requested types in each vehicle + for vehicle in service.vehicle_ids: + if self.env['fleet.vehicle'].search( + [('id', '=', vehicle.id), + ('vehicle_category_id', '=', request.vehicle_category_id.id), + ]): + available_qty += 1 + if available_qty < request.min_qty: + self.vehicle_check += (_('Missing %s (min %s)\n') + % (request.vehicle_category_id.name, + request.min_qty) + ) + if available_qty > request.max_qty > 0: + self.vehicle_check += (_('Too many %s (max %s)\n') + % (request.vehicle_category_id.name, + request.max_qty) + ) + if self.vehicle_check == '': + self.vehicle_check = 'All covered' + + return {'message': self.vehicle_check, + 'result': self.vehicle_check == 'All covered', + 'data': {} + } + + # ################################################################################## + # CHECK RESOURCE RULES + + def double_assign(self, parameters): + """ + Call rule for double assignment check + @param parameters dict + @param resource_type string: all | employee | equipment | vehicle + @param srv_id int: service id + """ + result = self.env['service.rule'].double_assign(parameters['resource_type'], + parameters['srv_id']) + return result + + def rule_call(self, parameters): + """ + Call a rule method + @param parameters dict: parameters to pass to the method + @param rule_name string: name of the method + @param param json: 'name:value' couples with method parameters + @param srv_id int: id of the service + @param res_obj obj: resourse object to check with the rule + """ + result = self.env['service.rule'].rule_call(parameters['rule_name'], + parameters['parameters'], + parameters['srv_id'], + parameters['res_obj'], + ) + return result + + def check_resource_rule(self, parameters): + """ + Check rules for each resource associated to the service + @param parameters dict: parameters to pass to the method + @param srv_id int: id of the service; -1 for all + @param res_obj obj: resourse object to check with the rule + @param date_star date: period initial date + @param date_stop date: perio final date + """ + + return_employee = self.check_employee_rule(parameters) + return_equipment = self.check_equipment_rule(parameters) + return_vehicle = self.check_vehicle_rule(parameters) + + return {'employee': return_employee, + 'equipment': return_equipment, + 'vehicle': return_vehicle, + } + + def check_employee_rule(self, parameters): + """ + Check rules for each resource associated to the service + @param parameters dict: parameters to pass to the method + @param srv_id int: id of the service + @param res_obj obj: resourse object to check with the rule + @param date_star date: period initial date + @param date_stop date: perio final date + _todo_ + check period limits + """ + employee_result = {'message': '', + 'result': True, + 'data': {}, + } + service_list = (self.env['service.allocate'] + .search([('id', '=', parameters['srv_id'])]) + if parameters['srv_id'] > 0 + else self.env['service.allocate'].search([]) + ) + for service in service_list: + # get employee of the service + for employee in service.employee_ids: + # memorize a dictionary of rules and fields + rule_method = {} + # get rules of the profile associated to the employee + for rule in employee.profile_id.parameter_ids: + # create rule element if not exists + try: + rule_method[rule.rule_id.method] + except: + rule_method[rule.rule_id.method] = {} + # save rule/field value + rule_method[rule.rule_id.method][rule.rule_field_id.field_name] = \ + rule.field_value + # _todo_ remove log + _logger.info(employee.name+' '+json.dumps(rule_method)) + # execute the memorized rules + for method in rule_method: + result = self.rule_call({ + 'rule_name': method, + 'parameters': json.dumps(rule_method[method]), + 'srv_id': employee.id, + 'res_obj': employee, + }) + result['data'].update(employee_result['data']) + employee_result = { + 'message': (employee_result['message'] + + employee.name + ': ' + result['message'] + '\n'), + 'result': True * result['result'], + 'data': result['data'], + } + return employee_result + + def check_vehicle_rule(self, parameters): + """ + Check rules for each vehicle associated to the service + @param parameters dict: parameters to pass to the method + @param srv_id int: id of the service + @param res_obj obj: resourse object to check with the rule + @param date_star date: period initial date + @param date_stop date: perio final date + _todo_ + check period limits + """ + vehicle_result = {'message': '', + 'result': True, + 'data': {}, + } + service_list = (self.env['service.allocate'] + .search([('id', '=', parameters['srv_id'])]) + if parameters['srv_id'] > 0 + else self.env['service.allocate'].search([]) + ) + for service in service_list: + # get vehicle of the service + for vehicle in service.vehicle_ids: + # memorize a dictionary of rules and fields + rule_method = {} + # get rules of the profile associated to the vehicle + for rule in vehicle.profile_id.parameter_ids: + # create rule element if not exists + try: + rule_method[rule.rule_id.method] + except: + rule_method[rule.rule_id.method] = {} + # save rule/field value + rule_method[rule.rule_id.method][rule.rule_field_id.field_name] = \ + rule.field_value + # _todo_ activate check logic on method + _logger.info(vehicle.name+' '+json.dumps(rule_method)) + # execute the memorized rules + for method in rule_method: + result = self.rule_call({ + 'rule_name': method, + 'parameters': json.dumps(rule_method[method]), + 'srv_id': vehicle.id, + 'res_obj': vehicle, + }) + result['data'].update(vehicle_result['data']) + vehicle_result = { + 'message': (vehicle_result['message'] + + vehicle.name + ': ' + result['message'] + '\n'), + 'result': True * result['result'], + 'data': result['data'], + } + return vehicle_result + + def check_equipment_rule(self, parameters): + """ + Check rules for each equipment associated to the service + @param parameters dict: parameters to pass to the method + @param srv_id int: id of the service + @param res_obj obj: resourse object to check with the rule + @param date_star date: period initial date + @param date_stop date: perio final date + _todo_ + check period limits + """ + equipment_result = {'message': '', + 'result': True, + 'data': {}, + } + service_list = (self.env['service.allocate'] + .search([('id', '=', parameters['srv_id'])]) + if parameters['srv_id'] > 0 + else self.env['service.allocate'].search([]) + ) + for service in service_list: + # get equipment of the service + for equipment in service.equipment_ids: + # memorize a dictionary of rules and fields + rule_method = {} + # get rules of the profile associated to the equipment + for rule in equipment.profile_id.parameter_ids: + # create rule element if not exists + try: + rule_method[rule.rule_id.method] + except: + rule_method[rule.rule_id.method] = {} + # save rule/field value + rule_method[rule.rule_id.method][rule.rule_field_id.field_name] = \ + rule.field_value + # _todo_ activate check logic on method + _logger.info(equipment.name+' '+json.dumps(rule_method)) + # execute the memorized rules + for method in rule_method: + result = self.rule_call({ + 'rule_name': method, + 'parameters': json.dumps(rule_method[method]), + 'srv_id': equipment.id, + 'res_obj': equipment, + }) + result['data'].update(equipment_result['data']) + equipment_result = { + 'message': (equipment_result['message'] + + equipment.name + ': ' + result['message'] + '\n'), + 'result': True * result['result'], + 'data': result['data'], + } + return equipment_result + + # ################################################################################## + # OVERRIDE ORIGINAL METHOD + + @api.model + def create(self, values): + """ + Override create function to manage next service generation + """ + new_service = super(ServiceAllocate, self).create(values) + + # generation id can be set by the automatic flow + if not new_service.generation_id: + new_service.generation_id = datetime.datetime.now(). \ + strftime("M %Y-%m-%d-%H-%M-%S") + new_service.check_skill_request() + new_service.check_equipment_request() + new_service.check_vehicle_request() + + # generate next service if present on template + if new_service.service_template_id.next_template_id.id: + # get end of the original service + next_strt = new_service.scheduled_stop + # get next service template + next_serv = new_service.service_template_id.next_template_id.id + # get first container of the next service template + next_cont = new_service.service_template_id.service_container_ids[0].id + + new_service_data = { + "service_template_id" : next_serv, + "service_container_id" : next_cont, + "scheduled_start" : next_strt, + "parent_service_id" : new_service.id, + "generation_id" : new_service.generation_id, + } + # clear checks for off duty services + if new_service.service_template_id.next_template_id.off_duty: + new_service_data["employee_check"] = '' + new_service_data["equipment_check"] = '' + new_service_data["vehicle_check"] = '' + + new_service_nxt = super(ServiceAllocate, self).create(new_service_data) + # save child reference + new_service.next_service_id = new_service_nxt + + return new_service + + @api.multi + def write(self, values): + """ + Override with check elements for double assigns before save + """ + ServiceAllocate_write = super(ServiceAllocate, self).write(values) + # call double assignment + self.double_assign({'resource_type': 'all', 'srv_id': self.id}) + + return ServiceAllocate_write + + @api.multi + def unlink(self): + """ + Override with unlink for next services + """ + # scan services for next element + for service in self: + # when next service is defined and not yet present in self list + if service.next_service_id.id and service.next_service_id not in self: + service.next_service_id.unlink() + + return super(ServiceAllocate, self).unlink() diff --git a/hr_duty_planner/models/service_container.py b/hr_duty_planner/models/service_container.py new file mode 100644 index 00000000000..7c49de0439d --- /dev/null +++ b/hr_duty_planner/models/service_container.py @@ -0,0 +1,20 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ServiceContainer(models.Model): + """ + General service definition that includes several time managed services + """ + + # model + _name = 'service.container' + _description = 'General service definition' + + # fields + # name + name = fields.Char('Name') + # description + description = fields.Char('Description') diff --git a/hr_duty_planner/models/service_profile.py b/hr_duty_planner/models/service_profile.py new file mode 100644 index 00000000000..83e0ce11078 --- /dev/null +++ b/hr_duty_planner/models/service_profile.py @@ -0,0 +1,23 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ServiceProfile(models.Model): + """ + Collection of rules to assign to a resource + """ + + # model + _name = 'service.profile' + _description = 'Profile to group rules' + + # fields + # name + name = fields.Char('Name', required=True) + # set rule ignore + rule_ignore = fields.Boolean('Ignore Rule', default=False, + help='If active all the Rules will not be checked') + # parameter values + parameter_ids = fields.Many2many('service.profileparameter', string='Field') diff --git a/hr_duty_planner/models/service_profile_parameter.py b/hr_duty_planner/models/service_profile_parameter.py new file mode 100644 index 00000000000..8cd48c850c3 --- /dev/null +++ b/hr_duty_planner/models/service_profile_parameter.py @@ -0,0 +1,25 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ServiceProfileParameter(models.Model): + """ + Assign values to the rules fields + """ + + # model + _name = 'service.profileparameter' + _description = 'Define value of fields used by rules in profile' + + # fields + # rule reference + rule_id = fields.Many2one('service.rule', 'Rule') + # rule field + rule_field_id = fields.Many2one('service.rulefield', 'Field') + # Value to use in method + field_value = fields.Char('Value') + + # define record name to display in form view + _rec_name = 'rule_id' diff --git a/hr_duty_planner/models/service_rule.py b/hr_duty_planner/models/service_rule.py new file mode 100644 index 00000000000..f201d1ac961 --- /dev/null +++ b/hr_duty_planner/models/service_rule.py @@ -0,0 +1,534 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models, _ +from odoo.exceptions import UserError +import json +import datetime +import logging + +_logger = logging.getLogger(__name__) + + +class ServiceRule(models.Model): + """ + Definition of the rules available as specific method. + This model if filled through xls data file. + """ + + # model + _name = 'service.rule' + _description = 'Rules to manage Service resources' + + # fields + # method of the rule + method = fields.Char('Method') + # rule description + description = fields.Char('Description') + # method's fields + field_ids = fields.Many2many('service.rulefield', string='Fields') + # define if rule is used + is_active = fields.Boolean('Active', default=True, + help='Set if the rule had to be evaluated') + + # define record name to display in form view + _rec_name = 'description' + + def double_assign(self, resource_type, obj_id): + """ + Check if a resource has more than one shift assigned at same time + @param resource_type string: type of the resource + [all | employee | vehicle | equipment] + @param obj_id int: id of the service; -1 to check all services + """ + + available_resource = ['all', 'employee', 'equipment', 'vehicle'] + rule_result = True + rule_msg = '' + + if resource_type not in available_resource: + return {'message': _('Resource not available: %s') % (resource_type), + 'result': False, + 'data': {} + } + + # select service to check + if obj_id > 0: + allocate_ids = self.env['service.allocate'].search([('id', '=', obj_id)]) + else: + allocate_ids = self.env['service.allocate'].search([]) + + # get the service data + for service in allocate_ids: + date_ini = service.scheduled_start + date_fin = service.scheduled_stop + + if resource_type in ('employee', 'all') : + # if it is a next service uses the parent employees for check + # if lock employee is activated + lock = service.parent_service_id.service_template_id.next_lock_employee + employee_list = ( + service.employee_ids + if not service.parent_service_id or not lock + else self.env['service.allocate'] + .search([('id', '=', service.parent_service_id.id), + ]).employee_ids + ) + for employee in employee_list: + all_services = self.env['service.allocate'] \ + .search([('id', '!=', service.id), + ('scheduled_start', '<', date_fin), + ('scheduled_stop', '>', date_ini), + ('state', '!=', 'closed') + ]) + for service_double in all_services: + # get employees of the parent if exists and template locks them + # otherwise service's employees + employee_ref = ( + service_double.parent_service_id.employee_ids + if service_double.parent_service_id and + service_double.parent_service_id + .service_template_id + .next_lock_employee + else service_double.employee_ids + ) + if employee in employee_ref: + rule_result = False + rule_msg += (('Shift %s/%s: %s\n') % (service.id, + service_double.id, + employee.name)) + + if resource_type in ('equipment', 'all'): + # if it is a next service uses the parent equipments for check + # if lock is activated + lock = service.parent_service_id.service_template_id.next_lock_equipment + equipment_list = ( + service.equipment_ids + if not service.parent_service_id or not lock + else self.env['service.allocate'] + .search([('id', '=', service.parent_service_id.id), + ]).equipment_ids + ) + for equipment in equipment_list: + all_services = self.env['service.allocate'] \ + .search([('id', '!=', service.id), + ('scheduled_start', '<', date_fin), + ('scheduled_stop', '>', date_ini), + ('state', '!=', 'closed') + ]) + for service_double in all_services: + # get equipments of the parent if exists and template locks them + # otherwise service's equipments + equipment_ref = ( + service_double.parent_service_id.equipment_ids + if service_double.parent_service_id and + service_double.parent_service_id + .service_template_id + .next_lock_equipment + else service_double.equipment_ids + ) + if equipment in equipment_ref: + rule_result = False + rule_msg += (('Shift %s/%s: %s\n') % (service.id, + service_double.id, + equipment.name)) + + if resource_type in ('vehicle', 'all'): + # if it is a next service uses the parent vehicles for check + # if lock is activated + lock = service.parent_service_id.service_template_id.next_lock_vehicle + vehicle_list = ( + service.vehicle_ids + if not service.parent_service_id or not lock + else self.env['service.allocate'].search( + [('id', '=', service.parent_service_id.id), + ]).vehicle_ids + ) + for vehicle in vehicle_list: + all_services = self.env['service.allocate'] \ + .search([('id', '!=', service.id), + ('scheduled_start', '<', date_fin), + ('scheduled_stop', '>', date_ini), + ('state', '!=', 'closed'), + ]) + for service_double in all_services: + # get vehicles of the parent if exists and template locks them + # otherwise service's vehicles + vehicle_ref = ( + service_double.parent_service_id.vehicle_ids + if service_double.parent_service_id and + service_double.parent_service_id + .service_template_id + .next_lock_vehicle + else service_double.vehicle_ids + ) + if vehicle in vehicle_ref: + rule_result = False + rule_msg += (('Shift %s/%s: %s\n') % (service.id, + service_double.id, + vehicle.name)) + + if obj_id > 0 and not rule_result: + raise UserError(_('Elements with overlapped shift:')+'\n'+rule_msg) + return {'message': _('Elements with overlapped shift:')+'\n'+rule_msg, + 'result': rule_result, + 'data': {} + } + + def rule_call(self, rule_name, parameters, srv_id, res_obj): + """ + Call requested rule + @param rule_name string: name of the rule + @param parameters json: 'name:value' couples of method's parameters + @param srv_id int: service id + @param res_obj obj: resource object + + @return dict: rule elaboration + """ + + # check if rule method is registered on model + if not self.env['service.rule'].search([('method', '=', rule_name)]): + return {'message': 'Rule method '+rule_name+' not registered.', + 'result': False, + 'data': {} + } + + # Get the method from 'self' or invalid method. + method = getattr(self, rule_name, "_invalid_method") + # Call the method selected + if method == '_invalid_method': + result = self._invalid_method(rule_name) + else: + result = method(parameters, srv_id, res_obj) + return result + + def _invalid_method(self, rule_name): + """ + Management of method non defined + """ + # _todo_ log/popup error management + # raise UserError(_('Method %s not defined') % (rule_name)) + _logger.error('ERROR invalid method: '+rule_name) + + return {'message': 'Method '+rule_name+' not defined.', + 'result': False, + 'data': {} + } + + #################################################################################### + # Rules's method definition + + def _rule_method_template(self, parameters, srv_id, res_obj): + """ + Rules template definition + + For standard development each rule method must have all the same parameters + defined: use false value in call if not used + + @param parameters json: 'name:value' couples of method's parameters + @param srv_id id: id of the service + @param res_obj obj: resource object + + @return dictionary: {'message': string, 'result': boolean, 'data': dict} + message: description of the elaboration result + result: True/False based on the logic + data: informations to be used by another method + """ + return {'message': 'Template', + 'result': True, + 'data': {} + } + + def hour_active_periond(self, parameters): + """ + Calculates the total of active hours of a resource in a period of time. + If period is not defined uses current year. + Active hours are on not off-duty services. + @param parameters dict: parameters to pass to the method + @param period dict: {'date_start':, 'date_stop': } period limits + @param srv_id int: service to analyze; -1 for all + @param res_obj obj: resourse object to analyze + + _todo_ manage on-call shift + """ + total_time = 0 + global_result = {'message': '', + 'result': True, + 'data': {}, + } + date_start = parameters['period']['date_star'] \ + if parameters['period'] and parameters['period']['date_star'] \ + else datetime.datetime(datetime.datetime.now().year, 1, 1) + date_stop = parameters['period']['date_stop'] \ + if parameters['period'] and parameters['period']['date_stop'] \ + else datetime.datetime(datetime.datetime.now().year, 12, 31) + + service_list = (self.env['service.allocate'] + .search([('id', '=', parameters['srv_id'])]) + if parameters['srv_id'] > 0 + else self.env['service.allocate'].search([]) + ) + for service in service_list: + for employee in service.employee_ids: + result = self.hour_active_periond_employee( + {'period' : {'date_start': date_start, + 'date_stop': date_stop, + }, + 'employee_id': employee.id, + }) + total_time += result['data']['hours'] + global_result['message'] += (_('Employees hours %s') % (total_time)) + '\n' + global_result['data']['hour_empl'] = total_time + + total_time = 0 + for service in service_list: + for equipment in service.equipment_ids: + result = self.hour_active_periond_equipment( + {'period' : {'date_start': date_start, + 'date_stop': date_stop, + }, + 'equipment_id': equipment.id, + }) + total_time += result['data']['hours'] + global_result['message'] += (_('Equipments hours %s') % (total_time)) + '\n' + global_result['data']['hour_eqpm'] = total_time + + total_time = 0 + for service in service_list: + for vehicle in service.vehicle_ids: + result = self.hour_active_periond_vehicle( + {'period' : {'date_start': date_start, + 'date_stop': date_stop, + }, + 'vehicle_id': vehicle.id, + }) + total_time += result['data']['hours'] + global_result['message'] += (_('Vehicle hours %s') % (total_time)) + '\n' + global_result['data']['hour_vhcl'] = total_time + + return {'message': global_result['message'], + 'result': True, + 'data': global_result['data'], + } + + def hour_active_periond_employee(self, parameters): + """ + Calculate the total of active hours of an employee in a period of time. + Active hours are on no off-duty services. + @param parameters dict: parameters to pass to the method + @param period dict: {'date_start':, 'date_stop': } period limit + @param employee_id int: employee to analyze + + _todo_ + manage on-call shift + manage real start/stop date + """ + total_time = 0 + # get services where employee is assigned + sql = ('SELECT service_allocate_id ' + 'FROM service_allocate ' + 'INNER JOIN hr_employee_service_allocate_rel ' + 'on service_allocate.id=service_allocate_id ' + 'WHERE ' + 'scheduled_start >= %s ' + 'and scheduled_stop <= %s ' + 'and hr_employee_id = %s ' + ) + self.env.cr.execute(sql, + (parameters['period']['date_start'], + parameters['period']['date_stop'], + str(parameters['employee_id']), + ) + ) + + # get duration of each service + for fetch_srv_id in self.env.cr.fetchall(): + total_time += self.env['service.allocate'] \ + .search([('id', 'in', fetch_srv_id)]) \ + .service_template_id.duration + # _todo_ + # raise UserError(_('Totale ore %s') % (total_time)) + return {'message': _('Totale ore: %s') % (total_time), + 'result': True, + 'data': {'hours': total_time} + } + + def hour_active_periond_equipment(self, parameters): + """ + Calculate the total of active hours of an equipment in a period of time. + Active hours are on no off-duty services. + @param parameters dict: parameters to pass to the method + @param period dict: {'date_start':, 'date_stop': } period limit + @param equipment_id int: equipment to analyze + + _todo_ + manage on-call shift + manage real start/stop date + """ + total_time = 0 + # get services where equipment is assigned + sql = ('SELECT service_allocate_id ' + 'FROM service_allocate ' + 'INNER JOIN maintenance_equipment_service_allocate_rel ' + 'on service_allocate.id=service_allocate_id ' + 'WHERE ' + 'scheduled_start >= %s ' + 'and scheduled_stop <= %s ' + 'and maintenance_equipment_id = %s ' + ) + self.env.cr.execute(sql, + (parameters['period']['date_start'], + parameters['period']['date_stop'], + str(parameters['equipment_id']), + ) + ) + + # get duration of each service + for fetch_srv_id in self.env.cr.fetchall(): + total_time += self.env['service.allocate'] \ + .search([('id', 'in', fetch_srv_id)]) \ + .service_template_id.duration + # _todo_ + # raise UserError(_('Totale ore %s') % (total_time)) + return {'message': _('Totale ore: %s') % (total_time), + 'result': True, + 'data': {'hours': total_time} + } + + def hour_active_periond_vehicle(self, parameters): + """ + Calculate the total of active hours of an vehicle in a period of time. + Active hours are on no off-duty services. + @param parameters dict: parameters to pass to the method + @param period dict: {'date_start':, 'date_stop': } period limit + @param vehicle_id int: vehicle to analyze + + _todo_ + manage on-call shift + manage real start/stop date + """ + total_time = 0 + # get services where vehicle is assigned + sql = ('SELECT service_allocate_id ' + 'FROM service_allocate ' + 'INNER JOIN fleet_vehicle_service_allocate_rel ' + 'on service_allocate.id=service_allocate_id ' + 'WHERE ' + 'scheduled_start >= %s ' + 'and scheduled_stop <= %s ' + 'and fleet_vehicle_id = %s ' + ) + self.env.cr.execute(sql, + (parameters['period']['date_start'], + parameters['period']['date_stop'], + str(parameters['vehicle_id']), + ) + ) + + # get duration of each service + for fetch_srv_id in self.env.cr.fetchall(): + total_time += self.env['service.allocate'] \ + .search([('id', 'in', fetch_srv_id)]) \ + .service_template_id.duration + # _todo_ + # raise UserError(_('Totale ore %s') % (total_time)) + return {'message': _('Totale ore: %s') % (total_time), + 'result': True, + 'data': {'hours': total_time} + } + + def hour_active_month(self, parameters): + """ + Calculate the total of active hours of a resource in the last 28 days. + Active hours are on no off-duty services. + @param parameters dict: {'date_start':} last day of month to check + @param srv_id int: service to analyze + @param res_obj obj: resourse object to analyze + """ + + date_stop = parameters['period']['date_stop'] \ + if parameters['period'] and parameters['period']['date_stop'] \ + else datetime.datetime.now() + date_start = date_stop - datetime.timedelta(days=28) + + result = self.env['service.rule'] \ + .hour_active_periond({'period': {'date_start': date_start, + 'date_stop': date_stop, + }, + 'srv_id': parameters['srv_id'], + 'res_obj': False, + }) + self.result_message = result['message'] + return result + + def hour_active_week(self, param, srv_id, res_obj): + """ + Calculate the total of active hours of a resource in a week. + By active hours is meant work+on call + + _todo_ define/set active shift + """ + total_time = 0 + + # extract employees of the service + for employee in (self.env['service.allocate'] + .search([('id', '=', srv_id)]).employee_ids): + # get services where employee is assigned + sql = ('SELECT service_allocate_id ' + 'FROM hr_employee_service_allocate_rel ' + 'WHERE hr_employee_id= %s') + self.env.cr.execute(sql, (str(employee.id),)) + # get duration of each service + # _todo_ calculate as end-start + for fetch_srv_id in self.env.cr.fetchall(): + total_time += self.env['service.allocate'] \ + .search([('id', 'in', fetch_srv_id)]) \ + .service_template_id.duration + # _todo_ + # raise UserError(_('Totale ore uomo setimana %s') % (total_time)) + return {'message': _('Totale ore uomo settimana %s') % (total_time), + 'result': True, + 'data': {'hours': total_time} + } + + def hour_rest_week(self, param, srv_id, res_obj): + """ + Calculate the total of rest hours of a resource in a week. + By active hours is meant not work or on call + _todo_ define/set active shift + """ + return {'message': 'hour_rest_week _ToDo_', + 'result': True, + 'data': {'hour': 8}} + + def hour_active_month_copy(self, param, srv_id, res_obj): + """ + _todo_ temporary copy to use/delete + + Calculate the total of active hours of a resource in a month. + By active hours is meant work+on call + + _todo_ define/set active shift + """ + total_time = 0 + + # get services where employee is assigned + sql = ('SELECT service_allocate_id ' + 'FROM hr_employee_service_allocate_rel ' + 'WHERE hr_employee_id= %s') + self.env.cr.execute(sql, (res_obj.id,)) + # get duration of each service + # _todo_ calculate as end-start + for fetch_srv_id in self.env.cr.fetchall(): + total_time += self.env['service.allocate'] \ + .search([('id', 'in', fetch_srv_id)]) \ + .service_template_id.duration + empl_name = self.env['hr.employee'].search([('id', '=', res_obj.id)]).name + param_dict = json.loads(param) + # _todo_ remove log + _logger.info(_('Totale ore (mese) %s: %s [limite %s]') + % (empl_name, total_time, param_dict['h_max'])) + return {'message': _('Total hours month: %s') % (total_time), + 'result': True, + 'data': {'hours': total_time} + } diff --git a/hr_duty_planner/models/service_rule_field.py b/hr_duty_planner/models/service_rule_field.py new file mode 100644 index 00000000000..516cbe50e21 --- /dev/null +++ b/hr_duty_planner/models/service_rule_field.py @@ -0,0 +1,34 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ServiceRuleField(models.Model): + """ + Definition of the fields used by rule's methods. + Field value type will be ever saved as string then converted inside methods + """ + + # model + _name = 'service.rulefield' + _description = 'fields available to rule\'s methods' + + # fields + # reference rule + rule_id = fields.Many2one('service.rule', string='Rule', required=True) + # field name used in the method + field_name = fields.Char('Field', required=True, + help='Name of the field used inside method') + # field description + field_desc = fields.Char('Description', required=True, + help='Describe what this field is used for') + # defualt value + field_default = fields.Char('Default value', + help='Value used if the field is not filled by user') + # set field mandatory + field_mandatory = fields.Boolean('Mandatory', default=False, + help='Indicate that the field has to be set') + + # define record name to display in form view + _rec_name = 'field_name' diff --git a/hr_duty_planner/models/service_state.py b/hr_duty_planner/models/service_state.py new file mode 100644 index 00000000000..9bb0ab2b50f --- /dev/null +++ b/hr_duty_planner/models/service_state.py @@ -0,0 +1,20 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ServiceState(models.Model): + """ + State of the allocated services + """ + + # model + _name = 'service.state' + _description = 'States of allocated services' + + # fields + # name + name = fields.Char('Name') + # description + description = fields.Char('Description') diff --git a/hr_duty_planner/models/service_template.py b/hr_duty_planner/models/service_template.py new file mode 100644 index 00000000000..39d18cb1a63 --- /dev/null +++ b/hr_duty_planner/models/service_template.py @@ -0,0 +1,113 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models, api, _ +from odoo.exceptions import ValidationError + + +class ServiceTemplate(models.Model): + """ + Model of a service with definition of the required components + """ + + # model + _name = 'service.template' + _description = 'Model of a service' + + # fields + # name + name = fields.Char('Name', required=True) + # container service reference + service_container_ids = fields.Many2many('service.container', + string='Container service', + required=True) + + # standard duration + duration = fields.Integer('Duration', required=True, default=1) + # duration uom + duration_uom_id = fields.Many2one('uom.uom', + string='Unit of Measure') + + # off-duty services identification to manage rest/maintenance conditions + off_duty = fields.Boolean('Off Duty', default=False, + help='Check if it is a technical ' + 'service to manage rest/maintenance conditions') + + # expected vehicles + exp_vhcl_type_ids = fields.Many2many('expected.vhcl_category', + string='Vehicles') + # expected skills + exp_empl_skill_ids = fields.Many2many('expected.skill', + string='Operator Skills') + # expected equipment category + exp_eqpmt_category_ids = fields.Many2many('expected.eqpmnt_cat', + string='Equipment Category') + + # product reference used to valorize + product_id = fields.Many2one('product.product', + string='Product reference') + + # identification color + base_color = fields.Char('Color') + + # Service Template to generate on Service completion + next_template_id = fields.Many2one( + 'service.template', + string='Next Service', + help='Service Template to insert after the end of this one', + ) + # Next service lock employees + next_lock_employee = fields.Boolean( + 'Lock Employees', default=True, + help='Next service will allocate Employees of the parent service', + ) + # Next service lock equipments + next_lock_equipment = fields.Boolean( + 'Lock Equipments', default=False, + help='Next service will allocate Equipments of the parent service', + ) + # Next service lock vehicles + next_lock_vehicle = fields.Boolean( + 'Lock Vehicles', default=False, + help='Next service will allocate Vehicles of the parent service', + ) + + @api.multi + @api.onchange('service_container_ids') + def _limit_container_count(self): + """ + Limit service container to 1 + """ + if self.off_duty and len(self.service_container_ids) > 1: + raise ValidationError(_('Only one Container Service allowed')) + return + + @api.multi + def write(self, values): + """ + Add control for fields value on record changes + """ + if ('duration' in values + and values['duration'] <= 0): + raise ValidationError(_('Enter a value for Duration')) + + if ('service_container_ids' in values + and len(values['service_container_ids'][0][2]) == 0): + raise ValidationError(_('Set a Container Service')) + + ServiceTemplate_write = super(ServiceTemplate, self).write(values) + return ServiceTemplate_write + + @api.model + def create(self, values): + """ + Add control for fields value on new records + """ + if ('duration' in values + and values['duration'] <= 0): + raise ValidationError(_('Enter a value for Duration')) + if ('service_container_ids' in values + and len(values['service_container_ids'][0][2]) == 0): + raise ValidationError(_('Set a Container Service')) + ServiceTemplate_create = super(ServiceTemplate, self).create(values) + return ServiceTemplate_create diff --git a/hr_duty_planner/models/service_utility.py b/hr_duty_planner/models/service_utility.py new file mode 100644 index 00000000000..e0d0d6297da --- /dev/null +++ b/hr_duty_planner/models/service_utility.py @@ -0,0 +1,96 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models, fields +import datetime + + +class ServiceUtility(models.Model): + """ + Utility model to create a management form + """ + + # model + _name = 'service.utility' + _description = 'Services utility' + + # fields + title = fields.Char(default='Dashboard') + result_message = fields.Text('Check result') + + # define record name to display in form view + _rec_name = 'title' + + def double_assign(self, parameters): + """ + Chek for double assignments + """ + result = self.env['service.rule'].double_assign(parameters['resource_type'], + parameters['srv_id']) + self.result_message = result['message'] + return result + + def rule_call(self, parameters): + """ + Call a rule method + @param parameters dict: parameters to pass to the method + rule_name string: name of the method + param json: [name:value] couples with method parameters + srv_id int: id of the service + res_obj obj: resourse object to check with the roule + """ + result = self.env['service.rule'].rule_call(parameters['rule_name'], + parameters['param'], + parameters['srv_id'], + parameters['res_obj'], + ) + self.result_message = result['message'] + return result + + def check_resource_rule(self, parameters): + """ + Check rules for each resource associated to the service + @param srv_id int: id of the service + """ + result = self.env['service.allocate'].check_resource_rule(parameters) + self.result_message = (result['employee']['message'] + + result['equipment']['message'] + + result['vehicle']['message']) + return result + + def hour_active_periond(self, parameters): + """ + Calculate the total of active hours of a resource in a period of time. + Active hours are on not off-duty services. + @param parameters dict: {'date_start':, 'date_stop': } period limit + @param srv_id int: service to analyze + @param res_obj obj: resourse object to analyze + """ + result = self.env['service.rule'] \ + .hour_active_periond({'period': False, + 'srv_id': parameters['srv_id'], + 'res_obj': False, + }) + self.result_message = result['message'] + return result + + def hour_active_month(self, parameters): + """ + Calculate the total of active hours of a resource in the last 28 days. + Active hours are on not off-duty services. + @param parameters dict: {'date_stop':} last day of month to check + @param srv_id int: service to analyze + @param res_obj obj: resourse object to analyze + """ + date_stop = parameters['period']['date_stop'] \ + if parameters['period'] and parameters['period']['date_stop'] \ + else datetime.datetime.now() + + result = self.env['service.rule'] \ + .hour_active_month({'period': {'date_stop': date_stop, + }, + 'srv_id': parameters['srv_id'], + 'res_obj': False, + }) + self.result_message = result['message'] + return result diff --git a/hr_duty_planner/models/vehicle_profile.py b/hr_duty_planner/models/vehicle_profile.py new file mode 100644 index 00000000000..f3732a19d05 --- /dev/null +++ b/hr_duty_planner/models/vehicle_profile.py @@ -0,0 +1,17 @@ +# Copyright 2020 Stefano Consolaro (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class VehicleProfile(models.Model): + """ + Add Profile reference to Vehicle + """ + + _inherit = 'fleet.vehicle' + + # Profile reference + profile_id = fields.Many2one('service.profile', + 'Service profile', + help='Vehicle service profile') diff --git a/hr_duty_planner/readme/CONTRIBUTORS.rst b/hr_duty_planner/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..31839b5202e --- /dev/null +++ b/hr_duty_planner/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Stefano Consolaro +* Marcelo Frare diff --git a/hr_duty_planner/readme/DESCRIPTION.rst b/hr_duty_planner/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..2b601adcca2 --- /dev/null +++ b/hr_duty_planner/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +These module manages the Service Planner. + +_TODO_ diff --git a/hr_duty_planner/readme/HISTORY.rst b/hr_duty_planner/readme/HISTORY.rst new file mode 100644 index 00000000000..4abe91a55d9 --- /dev/null +++ b/hr_duty_planner/readme/HISTORY.rst @@ -0,0 +1,4 @@ +12.0.1.0.0 (2020-05-01) +~~~~~~~~~~~~~~~~~~~~~~~ + +* [INI] Initial development diff --git a/hr_duty_planner/readme/USAGE.rst b/hr_duty_planner/readme/USAGE.rst new file mode 100644 index 00000000000..56d4e95a3d9 --- /dev/null +++ b/hr_duty_planner/readme/USAGE.rst @@ -0,0 +1,54 @@ +**Steps to follow to use Service Planner** + +**Set up** + + **Rules** + * Rules and Rules Fields can not be modified by Users. Read what they do and how to use fields. + + **Select Rules → Profile** + * Create a Profile + * Add rule fields with values + * Add profile to Employees: Employees → get one → fill Service Profile + * Add profile to Vehicle: Fleet → get one → fill Service Profile + * Add profile to Equipment: Maintenance → Equipments → get one → fill Service Profile + + **Employee** + * Create desired Skills: get an Employee → Skill tab → add row → create + + **Maintenance → Configuration → Categories** + * Create desired Categories + + **Fleet** + * Create desired Types: get a Vehicle → Vehicle Type → create + + **Select Template → Container Service** + * Create a list of Container Services to collect single services + + **Select Template → Service Template** + * Create a Template Service + + * Name, Container Service and Duration are mandatory + * Check if it is an Off Duty Service (normally used to track some informations, ie. rest shifts) + * Add requested Features to be sure to not forget to assign Resources. + (Minimum quantity if zero will not be considered) + * Optional, associate a Next Service: this will be automatically generated starting form the end of this one. + * Optional, set resources of parent service locked by the next service + + **Select Template → Service Template → your template** + * Clik on *Generate Services* button + * Template is preselected + * Select a Container + * Select a start date-time + * Select a stop date-time + * Set repetition interval (hours). If interval is inferior to the template duration, this one will be used + * Set available days of week + * Set to use work Calendar + * Set to use holiday + * Click on *Generate* button + + **Planning** + * Select Services → List view* + * Select a Service + * Fill resources (eg. Employee, Equipments, Vehicles) + * Check requirements (Template requestested Resources) fulfillment + * Calendar/Timeline: overview / move services diff --git a/hr_duty_planner/reports/__init__.py b/hr_duty_planner/reports/__init__.py new file mode 100644 index 00000000000..dd133e15ec5 --- /dev/null +++ b/hr_duty_planner/reports/__init__.py @@ -0,0 +1 @@ +from . import xlsx_allocated_service diff --git a/hr_duty_planner/reports/xlsx_allocated_service.py b/hr_duty_planner/reports/xlsx_allocated_service.py new file mode 100644 index 00000000000..b155997b53a --- /dev/null +++ b/hr_duty_planner/reports/xlsx_allocated_service.py @@ -0,0 +1,332 @@ +# Copyright 2020 Marcelo Frare (Ass. PNLUG - Gruppo Odoo ) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models +from odoo.tools.translate import _ +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT +from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT + + +class XlsxAllocatedService(models.AbstractModel): + """ + Define report to extract service data in xls format + """ + + _name = 'report.hr_duty_planner.xlsx_allocated_service' + _description = 'Support to xls export' + _inherit = 'report.report_xlsx.abstract' + + def generate_xlsx_report(self, workbook, data, extra): + """ + Report type call management + """ + layout_report = data['form']['layout_report'] + + if layout_report == 'pivot': + self._export_pivot(workbook, data) + else: + self._export_list(workbook, data) + + def _export_list(self, workbook, data): + """ + Extract basic data in a simple table list + """ + + # memorize data + ids = data['ids'] + services = self.env['service.allocate'].browse(ids) + date_from = data['form']['date_from'] + date_to = data['form']['date_to'] + + # define report parameters + sheet = workbook.add_worksheet(_('Service allocate')) + sheet.set_landscape() + sheet.fit_to_pages(1, 0) + sheet.set_column(0, 1, 20) + sheet.set_column(2, 3, 35) + sheet.set_column(4, 7, 15) + sheet.set_column(8, 11, 35) + + bold_style = workbook.add_format({'bold': True, + 'font_size': 14, + 'bottom': 1, + }) + + title_style = workbook.add_format({'bold': False, + 'align': 'center', + 'bg_color': '#C0C0C0', + 'bottom': 1, + }) + + center_style = workbook.add_format({'bold': False, + 'align': 'center', + 'valign': 'top', + }) + + left_style = workbook.add_format({'bold': False, + 'align': 'left', + 'valign': 'top', + }) + + right_style = workbook.add_format({'bold': False, + 'align': 'right', + 'valign': 'top', + }) + + # create rows + row = 0 + header = False + for service in services: + # create header + if not header: + header = True + sheet.write(row, 0, _('Allocated service'), bold_style) + sheet.write(row, 3, _('From:'), bold_style) + from_date = date_from[8:10] + '/' + date_from[5:7] + '/' + date_from[:4] + sheet.write(row, 4, from_date, bold_style) + sheet.write(row, 5, _('To:'), bold_style) + to_date = date_to[8:10] + '/' + date_to[5:7] + '/' + date_to[:4] + sheet.write(row, 6, to_date, bold_style) + + row += 2 + self.header_ = [_('Scheduled start'), + _('Scheduled stop'), + _('Container'), + _('Name'), + _('State'), + _('Duration'), + _('Uom'), + _('Employees'), + _('Vehicles'), + _('Equipments'), + ] + + sheet_title = self.header_ + sheet.write_row(row, 0, sheet_title, title_style) + row += 1 + + # format row data + s_start = service.scheduled_start.strftime(DEFAULT_SERVER_DATETIME_FORMAT) + s_stop = service.scheduled_stop.strftime(DEFAULT_SERVER_DATETIME_FORMAT) + s_container = service.service_container_id.name + s_name = service.service_template_id.name + s_state = service.state + s_duration = service.service_template_id.duration + s_duration_uom = service.service_template_id.duration_uom_id.name + + # used to set row height + max_rows = 1 + + # collect row employees + s_employees = '' + for employee in service.employee_ids: + s_employees += employee.name + ';\n' + if s_employees: + s_employees = s_employees[:-2] + if len(service.employee_ids) > max_rows: + max_rows = len(service.employee_ids) + + # collect row vehicles + s_vehicles = '' + for vehicle in service.vehicle_ids: + s_vehicles += vehicle.name + ';\n' + if s_vehicles: + s_vehicles = s_vehicles[:-2] + if len(service.vehicle_ids) > max_rows: + max_rows = len(service.vehicle_ids) + + # collect row equipments + s_equipments = '' + for equipment in service.equipment_ids: + s_equipments += equipment.name + ';\n' + if s_equipments: + s_equipments = s_equipments[:-2] + if len(service.equipment_ids) > max_rows: + max_rows = len(service.equipment_ids) + + # row height + if max_rows > 1: + sheet.set_row(row, 25 * (max_rows - 1)) + + # row details + sheet.write(row, 0, s_start or '', center_style) + sheet.write(row, 1, s_stop or '', center_style) + sheet.write(row, 2, s_container or '', left_style) + sheet.write(row, 3, s_name or '', left_style) + sheet.write(row, 4, s_state or '', left_style) + sheet.write(row, 5, s_duration or '', right_style) + sheet.write(row, 6, s_duration_uom or '', left_style) + sheet.write(row, 7, s_employees or '', left_style) + sheet.write(row, 8, s_vehicles or '', left_style) + sheet.write(row, 9, s_equipments or '', left_style) + + row += 1 + return row + + def _export_pivot(self, workbook, data): + """ + Generate a pivot table: use as example + """ + + # memorize data + ids = data['ids'] + services = self.env['service.allocate'].browse(ids) + date_from = data['form']['date_from'] + date_to = data['form']['date_to'] + + # mapped scheduled_start + datestime_start = sorted(set(services.mapped('scheduled_start'))) + + # _todo_ if possible optimized mapped on only date? + dates_start = [] + for datetime_start in datestime_start: + date_start = datetime_start.strftime(DEFAULT_SERVER_DATE_FORMAT) + if date_start not in dates_start: + dates_start.append(date_start) + + # set table formats + sheet = workbook.add_worksheet(_('Service allocate')) + sheet.set_landscape() + sheet.fit_to_pages(1, 0) + sheet.set_column(0, 1, 20) + sheet.set_column(2, 2, 15) + sheet.set_column(3, 30, 25) + + bold_style = workbook.add_format({'bold': True, + 'font_size': 14, + 'bottom': 1}) + + title_style = workbook.add_format({'bold': False, + 'align': 'center', + 'bg_color': '#C0C0C0', + 'bottom': 1}) + + left_style = workbook.add_format({'bold': False, + 'align': 'left', + 'valign': 'top'}) + + # rows + row = 0 + + # used to write only one time each record + already_read = [] + + # used to add header + header = False + + for service in services: + # create header + if not header: + header = True + sheet.write(0, 0, _('Allocated service'), bold_style) + sheet.write(1, 0, _('From:'), bold_style) + from_date = date_from[8:10] + '/' + date_from[5:7] + '/' + date_from[:4] + sheet.write(1, 1, from_date, bold_style) + sheet.write(2, 0, _('To:'), bold_style) + to_date = date_to[8:10] + '/' + date_to[5:7] + '/' + date_to[:4] + sheet.write(2, 1, to_date, bold_style) + + # dates + col = 3 + for date_start in dates_start: + sheet.write(2, col, date_start, title_style) + col += 1 + + if service.id not in already_read: + + # service in focus + focus_container = service.service_container_id + focus_template = service.service_template_id + focus_start = service.scheduled_start.strftime('%H:%M:%S') + focus_stop = service.scheduled_stop.strftime('%H:%M:%S') + + # row identification + row += 3 + s_container = service.service_container_id.name + s_name = (service.service_template_id.name + '\n' + + focus_start + '-' + focus_stop + '\n' + + str(service.service_template_id.duration) + ) + + sheet.write(row, 0, s_container or '', left_style) + sheet.write(row, 1, s_name or '', left_style) + sheet.write(row, 2, _('Employees'), left_style) + sheet.write(row + 1, 2, _('Vehicles'), left_style) + sheet.write(row + 2, 2, _('Equipments'), left_style) + + # write service + self._write_service(service, row, sheet, workbook, dates_start) + + # memorize worked record + already_read.append(service.id) + + for find in services: + if focus_container == find.service_container_id and \ + focus_template == find.service_template_id and \ + focus_start == find.scheduled_start.strftime('%H:%M:%S') and \ + focus_stop == find.scheduled_stop.strftime('%H:%M:%S'): + + if find.id not in already_read: + # write service + self._write_service(find, row, sheet, workbook, dates_start) + + # memorize worked record + already_read.append(find.id) + return row + + def _write_service(self, service, row, sheet, workbook, dates_start): + """ + Write content of a pivot report cell + """ + + left_style = workbook.add_format({'bold': False, + 'align': 'left', + 'valign': 'top'}) + + # locate column + col = 3 + for date_start in dates_start: + if date_start == service.scheduled_start \ + .strftime(DEFAULT_SERVER_DATE_FORMAT): + break + col += 1 + + # used to set row height + max_rows = 3 + + # collect employees data + s_employees = '' + for employee in service.employee_ids: + s_employees += employee.name + ';\n' + if s_employees: + s_employees = s_employees[:-2] + if len(service.employee_ids) > max_rows: + max_rows = len(service.employee_ids) + + # collect vehicles data + s_vehicles = '' + for vehicle in service.vehicle_ids: + s_vehicles += vehicle.name + ';\n' + if s_vehicles: + s_vehicles = s_vehicles[:-2] + if len(service.vehicle_ids) > max_rows: + max_rows = len(service.vehicle_ids) + + # collect equipments data + s_equipments = '' + for equipment in service.equipment_ids: + s_equipments += equipment.name + ';\n' + if s_equipments: + s_equipments = s_equipments[:-2] + if len(service.equipment_ids) > max_rows: + max_rows = len(service.equipment_ids) + + # row height + sheet.set_row(row, 20 * max_rows) + sheet.set_row(row+1, 20 * max_rows) + sheet.set_row(row+2, 20 * max_rows) + + # write row details + sheet.write(row, col, s_employees or '', left_style) + sheet.write(row+1, col, s_vehicles or '', left_style) + sheet.write(row+2, col, s_equipments or '', left_style) diff --git a/hr_duty_planner/reports/xlsx_allocated_service.xml b/hr_duty_planner/reports/xlsx_allocated_service.xml new file mode 100644 index 00000000000..23d8c8e5a6f --- /dev/null +++ b/hr_duty_planner/reports/xlsx_allocated_service.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/hr_duty_planner/security/ir.model.access.csv b/hr_duty_planner/security/ir.model.access.csv new file mode 100644 index 00000000000..c79f6d2817f --- /dev/null +++ b/hr_duty_planner/security/ir.model.access.csv @@ -0,0 +1,14 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_expected_vhcl_category,access_expected_vhcl_category,model_expected_vhcl_category,base.group_user,1,1,1,1 +access_expected_eqpmnt_cat,access_expected_eqpmnt_cat,model_expected_eqpmnt_cat,base.group_user,1,1,1,1 +access_expected_skill,access_expected_skill,model_expected_skill,base.group_user,1,1,1,1 +access_service_container,access_service_container,model_service_container,base.group_user,1,1,1,1 +access_service_state,access_service_state,model_service_state,base.group_user,1,1,1,1 +access_service_template,access_service_template,model_service_template,base.group_user,1,1,1,1 +access_service_allocate,access_service_allocate,model_service_allocate,base.group_user,1,1,1,1 +access_service_rule,access_service_rule,model_service_rule,base.group_user,1,1,0,0 +access_service_profile,access_service_profile,model_service_profile,base.group_user,1,1,1,1 +access_service_profileparameter,access_service_profileparameter,model_service_profileparameter,base.group_user,1,1,1,1 +access_service_rulefield,access_service_rulefield,model_service_rulefield,base.group_user,1,1,0,0 +access_service_aggregator,access_service_aggregator,model_service_aggregator,base.group_user,1,1,1,1 +access_service_utility,access_service_utility,model_service_utility,base.group_user,1,1,1,1 diff --git a/hr_duty_planner/static/description/icon.png b/hr_duty_planner/static/description/icon.png new file mode 100644 index 00000000000..6d6ceca9b92 Binary files /dev/null and b/hr_duty_planner/static/description/icon.png differ diff --git a/hr_duty_planner/static/description/index.html b/hr_duty_planner/static/description/index.html new file mode 100644 index 00000000000..23150bed40d --- /dev/null +++ b/hr_duty_planner/static/description/index.html @@ -0,0 +1,517 @@ + + + + + + +HR - Duty Planner + + + +
+

HR - Duty Planner

+ + +

Beta License: AGPL-3 OCA/hr Translate me on Weblate Try me on Runbot

+

These module manages the Service Planner.

+

_TODO_

+

Table of contents

+ +
+

Usage

+

Steps to follow to use Service Planner

+

Set up

+
+
+
Rules
+
    +
  • Rules and Rules Fields can not be modified by Users. Read what they do and how to use fields.
  • +
+
+
Select Rules → Profile
+
    +
  • Create a Profile
  • +
  • Add rule fields with values
  • +
  • Add profile to Employees: Employees → get one → fill Service Profile
  • +
  • Add profile to Vehicle: Fleet → get one → fill Service Profile
  • +
  • Add profile to Equipment: Maintenance → Equipments → get one → fill Service Profile
  • +
+
+
Employee
+
    +
  • Create desired Skills: get an Employee → Skill tab → add row → create
  • +
+
+
Maintenance → Configuration → Categories
+
    +
  • Create desired Categories
  • +
+
+
Fleet
+
    +
  • Create desired Types: get a Vehicle → Vehicle Type → create
  • +
+
+
Select Template → Container Service
+
    +
  • Create a list of Container Services to collect single services
  • +
+
+
Select Template → Service Template
+
    +
  • Create a Template Service
  • +
+
+
    +
  • Name, Container Service and Duration are mandatory
  • +
  • Check if it is an Off Duty Service (normally used to track some informations, ie. rest shifts)
  • +
  • Add requested Features to be sure to not forget to assign Resources. +(Minimum quantity if zero will not be considered)
  • +
  • Optional, associate a Next Service: this will be automatically generated starting form the end of this one.
  • +
  • Optional, set resources of parent service locked by the next service
  • +
+
+
+
Select Template → Service Template → your template
+
    +
  • Clik on Generate Services button
  • +
  • Template is preselected
  • +
  • Select a Container
  • +
  • Select a start date-time
  • +
  • Select a stop date-time
  • +
  • Set repetition interval (hours). If interval is inferior to the template duration, this one will be used
  • +
  • Set available days of week
  • +
  • Set to use work Calendar
  • +
  • Set to use holiday
  • +
  • Click on Generate button
  • +
+
+
Planning
+
    +
  • Select Services → List view*
  • +
  • Select a Service
  • +
  • Fill resources (eg. Employee, Equipments, Vehicles)
  • +
  • Check requirements (Template requestested Resources) fulfillment
  • +
  • Calendar/Timeline: overview / move services
  • +
+
+
+
+
+
+

Changelog

+
+

12.0.1.0.0 (2020-05-01)

+
    +
  • [INI] Initial development
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+ +
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/hr project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/hr_duty_planner/static/src/css/hr_duty_planner.css b/hr_duty_planner/static/src/css/hr_duty_planner.css new file mode 100644 index 00000000000..22ae2775a94 --- /dev/null +++ b/hr_duty_planner/static/src/css/hr_duty_planner.css @@ -0,0 +1,3 @@ +.sp_td_label .o_td_label { + text-align: right !important; + } \ No newline at end of file diff --git a/hr_duty_planner/views/employee_profile.xml b/hr_duty_planner/views/employee_profile.xml new file mode 100644 index 00000000000..0b86201b6b0 --- /dev/null +++ b/hr_duty_planner/views/employee_profile.xml @@ -0,0 +1,18 @@ + + + + + + hr.employee + hr.employee + + + + + + + + diff --git a/hr_duty_planner/views/equipment_profile.xml b/hr_duty_planner/views/equipment_profile.xml new file mode 100644 index 00000000000..55a5f2c1b64 --- /dev/null +++ b/hr_duty_planner/views/equipment_profile.xml @@ -0,0 +1,18 @@ + + + + + + maintenance.equipment + maintenance.equipment + + + + + + + + diff --git a/hr_duty_planner/views/service_aggregator_view.xml b/hr_duty_planner/views/service_aggregator_view.xml new file mode 100644 index 00000000000..ce7043d7f30 --- /dev/null +++ b/hr_duty_planner/views/service_aggregator_view.xml @@ -0,0 +1,38 @@ + + + + + + + Service Aggregator + service.aggregator + + + + + + + + + + + Service Aggregator + service.aggregator + +
+ +

+
+ + + + +
+
+
+
+ +
diff --git a/hr_duty_planner/views/service_allocate_view.xml b/hr_duty_planner/views/service_allocate_view.xml new file mode 100644 index 00000000000..cf8ddc9a4bd --- /dev/null +++ b/hr_duty_planner/views/service_allocate_view.xml @@ -0,0 +1,203 @@ + + + + + + + Service Allocated + service.allocate + + + +