237 lines
11 KiB
Python
237 lines
11 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
from datetime import date
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import format_date
|
|
from odoo.tools.misc import file_path
|
|
|
|
MONTH_SELECTION = [
|
|
('1', 'January'), ('2', 'February'), ('3', 'March'), ('4', 'April'),
|
|
('5', 'May'), ('6', 'June'), ('7', 'July'), ('8', 'August'),
|
|
('9', 'September'), ('10', 'October'), ('11', 'November'), ('12', 'December'),
|
|
]
|
|
AREA_CODE_MAP = {
|
|
'HK': 'H', # Hong Kong Island
|
|
'KLN': 'K', # Kowloon
|
|
'NT': 'N', # New Territories
|
|
# 'F' is used as a default for foreign or unspecified areas later in the code.
|
|
}
|
|
|
|
|
|
class L10n_HkIrd(models.AbstractModel):
|
|
_name = 'l10n_hk.ird'
|
|
_inherit = ['hr.payroll.declaration.mixin']
|
|
_description = 'IRD Sheet'
|
|
_order = 'start_period'
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
if self.env.company.country_id.code != 'HK':
|
|
raise UserError(self.env._('You must be logged in a Hong Kong company to use this feature.'))
|
|
if not self.env.company.l10n_hk_employer_name or not self.env.company.l10n_hk_employer_file_number:
|
|
raise UserError(self.env._("Please configure the Employer's Name and the Employer's File Number in the company settings."))
|
|
return super().default_get(fields)
|
|
|
|
display_name = fields.Char()
|
|
state = fields.Selection([('draft', 'Draft'), ('waiting', 'Waiting'), ('done', 'Done')], default='draft')
|
|
currency_id = fields.Many2one('res.currency', related='company_id.currency_id')
|
|
start_year = fields.Integer(required=True, default=lambda self: fields.Date.today().year - 1)
|
|
start_month = fields.Selection(MONTH_SELECTION, required=True, default='4')
|
|
start_period = fields.Date('Start Period', compute='_compute_period', store=True)
|
|
end_year = fields.Integer(required=True, default=lambda self: fields.Date.today().year)
|
|
end_month = fields.Selection(MONTH_SELECTION, required=True, default='3')
|
|
end_period = fields.Date('End Period', compute='_compute_period', store=True)
|
|
submission_date = fields.Date('Submission Date', default=fields.Date.today, required=True)
|
|
year_of_employer_return = fields.Char("Year of Employer's Return")
|
|
name_of_signer = fields.Char("Name of Signer", required=True)
|
|
designation_of_signer = fields.Char("Designation of Signer", required=True)
|
|
type_of_form = fields.Selection(
|
|
[('O', "Original"), ('A', "Additional"), ('R', "Replacement")],
|
|
"Type Of Form", default='O', required=True
|
|
)
|
|
xml_file = fields.Binary(string="XML File")
|
|
xml_filename = fields.Char("XML Filename")
|
|
xml_validation_state = fields.Selection([
|
|
('normal', 'N/A'),
|
|
('done', 'Valid'),
|
|
('invalid', 'Invalid'),
|
|
], default='normal', compute='_compute_validation_state', store=True)
|
|
error_message = fields.Char('Error Message', compute='_compute_validation_state', store=True)
|
|
pdf_error = fields.Text('PDF Error Message')
|
|
|
|
def _country_restriction(self):
|
|
return 'HK'
|
|
|
|
@api.constrains('year_of_employer_return')
|
|
def _check_year_of_employer_return(self):
|
|
for report in self.filtered(lambda c: c.year_of_employer_return):
|
|
if year := report.year_of_employer_return:
|
|
if not year.isdecimal() or len(year) != 4:
|
|
raise UserError(self.env._("The year of employer's return must be a 4 digits number."))
|
|
if int(year) > fields.Date.today().year:
|
|
raise UserError(self.env._("The year of employer's return must be in the past."))
|
|
|
|
@api.depends('start_year', 'start_month', 'end_year', 'end_month')
|
|
def _compute_period(self):
|
|
for record in self:
|
|
record.start_period = date(record.start_year, int(record.start_month), 1)
|
|
record.end_period = date(record.end_year, int(record.end_month), 1) + relativedelta(day=31)
|
|
|
|
@api.depends('start_period')
|
|
def _compute_display_name(self):
|
|
for sheet in self:
|
|
if sheet.start_period:
|
|
sheet.display_name = format_date(self.env, sheet.start_period, date_format="MMMM y", lang_code=self.env.user.lang or 'en_US')
|
|
else:
|
|
sheet.display_name = self.env._("IRD Sheet")
|
|
|
|
@api.depends('xml_file')
|
|
def _compute_validation_state(self):
|
|
for record in self:
|
|
record.xml_validation_state = 'normal'
|
|
record.error_message = ''
|
|
|
|
@api.model
|
|
def _get_xml_resource(self, file_name):
|
|
return file_path(f'l10n_hk_hr_payroll/data/xml_schema/{file_name}')
|
|
|
|
def _validate_employee_addresses(self, employees):
|
|
invalid_employees = employees.filtered(lambda e: not e.private_street or not e.private_state_id)
|
|
if invalid_employees:
|
|
return self.env._("The following employees don't have a valid private address (with a street and a state): %s", ', '.join(invalid_employees.mapped('name')))
|
|
return None
|
|
|
|
def _validate_employee_personal_info(self, employees):
|
|
invalid_employees = employees.filtered(lambda e: not e.l10n_hk_surname or not e.l10n_hk_given_name or not e.sex)
|
|
if invalid_employees:
|
|
return self.env._("Please configure a surname, a given name and a sex for the following employees: %s", ', '.join(invalid_employees.mapped('name')))
|
|
return None
|
|
|
|
def _validate_employee_identification(self, employees):
|
|
invalid_employees = employees.filtered(lambda e: not e.identification_id and not e.passport_id)
|
|
if invalid_employees:
|
|
return self.env._("Please configure a HKID or a passport number for the following employees: %s", ', '.join(invalid_employees.mapped('name')))
|
|
return None
|
|
|
|
def _validate_employee_rental_records(self, employees):
|
|
invalid_employees = employees.filtered(lambda emp: len(emp.l10n_hk_rental_ids.filtered_domain([
|
|
('state', 'in', ['open', 'close']),
|
|
('date_start', '<=', self.end_period),
|
|
'|', ('date_end', '>', self.start_period), ('date_end', '=', False),
|
|
])) > 2)
|
|
if invalid_employees:
|
|
return self.env._("Some employee have more than 2 rental records within the period:\n%s", '\n'.join(invalid_employees.mapped('name')))
|
|
return None
|
|
|
|
@api.model
|
|
def _check_employees(self, employees):
|
|
if not employees:
|
|
return self.env._("You must select at least one employee.")
|
|
|
|
error_messages = []
|
|
error_messages.append(self._validate_employee_addresses(employees))
|
|
error_messages.append(self._validate_employee_personal_info(employees))
|
|
error_messages.append(self._validate_employee_identification(employees))
|
|
error_messages.append(self._validate_employee_rental_records(employees))
|
|
|
|
return '\n'.join(filter(None, error_messages))
|
|
|
|
def _get_employees_payslip_data(self, employees):
|
|
self.ensure_one()
|
|
|
|
salary_structure = self.env.ref('l10n_hk_hr_payroll.hr_payroll_structure_cap57_employee_salary', raise_if_not_found=False)
|
|
if not salary_structure:
|
|
raise UserError(self.env._("Salary structure 'CAP57: Employees Monthly Pay' not found."))
|
|
|
|
all_payslips = self.env['hr.payslip'].search([
|
|
('state', 'in', ['validated', 'paid']),
|
|
('date_from', '>=', self.start_period),
|
|
('date_to', '<=', self.end_period),
|
|
('employee_id', 'in', employees.ids),
|
|
('struct_id', '=', salary_structure.id),
|
|
])
|
|
|
|
if not all_payslips:
|
|
raise UserError(self.env._('There are no confirmed payslips using the CAP57 structure for the selected employees in this period.'))
|
|
|
|
employee_payslips_map = defaultdict(lambda: self.env['hr.payslip'])
|
|
for payslip in all_payslips:
|
|
employee_payslips_map[payslip.employee_id] |= payslip
|
|
|
|
return {'all_payslips': all_payslips, 'employee_payslips_map': employee_payslips_map}
|
|
|
|
def _get_report_info_data(self):
|
|
self.ensure_one()
|
|
file_number = ''
|
|
company = self.company_id
|
|
if company.l10n_hk_employer_file_number:
|
|
file_number = company.l10n_hk_employer_file_number.strip()
|
|
section, ern = file_number.split('-')
|
|
|
|
company_address_parts = [
|
|
company.street, company.street2, company.city,
|
|
company.state_id.name if company.state_id else None,
|
|
company.country_id.name if company.country_id else None
|
|
]
|
|
company_address = ', '.join(filter(None, company_address_parts))
|
|
|
|
return {
|
|
'FileNo': file_number,
|
|
'Section': section,
|
|
'ERN': ern.upper(),
|
|
'YrErReturn': self.year_of_employer_return if self.type_of_form == 'O' else '',
|
|
'SubDate': self.submission_date,
|
|
'ErName': company.l10n_hk_employer_name,
|
|
'company_address': company_address,
|
|
'NAME_OF_SIGNER': self.name_of_signer,
|
|
'Designation': self.designation_of_signer,
|
|
}
|
|
|
|
def _get_employee_data(self, employee):
|
|
data = {
|
|
'employee': employee,
|
|
'employee_id': employee.id,
|
|
'HKID': employee.identification_id.strip().upper() if employee.identification_id else '',
|
|
'Surname': employee.l10n_hk_surname,
|
|
'GivenName': employee.l10n_hk_given_name,
|
|
'NameInChinese': employee.l10n_hk_name_in_chinese,
|
|
'Sex': 'M' if employee.sex == 'male' else 'F',
|
|
'PpNum': '',
|
|
'RES_ADDR_LINE1': employee.private_street,
|
|
'RES_ADDR_LINE2': employee.private_street2,
|
|
'RES_ADDR_LINE3': employee.private_city,
|
|
'employee_address': '',
|
|
'AreaCodeResAddr': AREA_CODE_MAP.get(employee.private_state_id.code, 'F'),
|
|
'Capacity': employee.job_title,
|
|
'date_of_commencement': employee._get_first_version_date(),
|
|
}
|
|
|
|
if not data['HKID']:
|
|
data['PpNum'] = ', '.join(filter(None, [employee.passport_id, employee.l10n_hk_passport_place_of_issue]))
|
|
|
|
data['employee_address'] = ', '.join(filter(None, [
|
|
employee.private_street, employee.private_street2, employee.private_city,
|
|
employee.private_state_id.name, employee.private_country_id.name
|
|
]))
|
|
return data
|
|
|
|
def _get_employee_spouse_data(self, employee):
|
|
data = {
|
|
'SpouseName': '',
|
|
'SpouseHKID': '',
|
|
'SpousePpNum': '',
|
|
'MaritalStatus': 2 if employee.marital == 'married' else 1,
|
|
}
|
|
|
|
if employee.marital == 'married':
|
|
data['SpouseName'] = employee.spouse_complete_name.upper() if employee.spouse_complete_name else ''
|
|
data['SpouseHKID'] = employee.l10n_hk_spouse_identification_id.strip().upper() if employee.l10n_hk_spouse_identification_id else ''
|
|
data['SpousePpNum'] = ', '.join(filter(None, [employee.l10n_hk_spouse_passport_id, employee.l10n_hk_spouse_passport_place_of_issue]))
|
|
|
|
return data
|