388 lines
14 KiB
Python
388 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from datetime import date, timedelta
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
|
|
class PayPeriod(models.Model):
|
|
"""
|
|
Pay Period Management
|
|
Stores configured pay periods with auto-generation of future periods.
|
|
"""
|
|
_name = 'payroll.pay.period'
|
|
_description = 'Pay Period'
|
|
_order = 'date_start asc' # Chronological: oldest first (scroll UP for past, scroll DOWN for future)
|
|
_rec_name = 'name'
|
|
|
|
display_order = fields.Integer(
|
|
string='Display Order',
|
|
compute='_compute_display_order',
|
|
store=True,
|
|
help='For reference only - actual ordering uses date_start desc',
|
|
)
|
|
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
string='Company',
|
|
required=True,
|
|
default=lambda self: self.env.company,
|
|
ondelete='cascade',
|
|
)
|
|
|
|
date_start = fields.Date(
|
|
string='Period Start',
|
|
required=True,
|
|
)
|
|
date_end = fields.Date(
|
|
string='Period End',
|
|
required=True,
|
|
)
|
|
pay_date = fields.Date(
|
|
string='Pay Date',
|
|
help='Date when employees receive payment',
|
|
)
|
|
|
|
schedule_type = fields.Selection([
|
|
('weekly', 'Weekly'),
|
|
('biweekly', 'Bi-Weekly'),
|
|
('semi_monthly', 'Semi-Monthly'),
|
|
('monthly', 'Monthly'),
|
|
], string='Schedule Type', required=True, default='biweekly')
|
|
|
|
state = fields.Selection([
|
|
('draft', 'Open'),
|
|
('in_progress', 'In Progress'),
|
|
('paid', 'Paid'),
|
|
('closed', 'Closed'),
|
|
], string='Status', default='draft', tracking=True)
|
|
|
|
payslip_run_id = fields.Many2one(
|
|
'hr.payslip.run',
|
|
string='Payslip Batch',
|
|
ondelete='set null',
|
|
)
|
|
|
|
name = fields.Char(
|
|
string='Period Name',
|
|
compute='_compute_name',
|
|
store=True,
|
|
)
|
|
|
|
is_current = fields.Boolean(
|
|
string='Current Period',
|
|
compute='_compute_is_current',
|
|
)
|
|
|
|
@api.depends('date_start', 'date_end')
|
|
def _compute_name(self):
|
|
for period in self:
|
|
if period.date_start and period.date_end:
|
|
period.name = f"{period.date_start.strftime('%m.%d.%Y')} to {period.date_end.strftime('%m.%d.%Y')}"
|
|
else:
|
|
period.name = _('New Period')
|
|
|
|
def _compute_is_current(self):
|
|
today = fields.Date.context_today(self)
|
|
for period in self:
|
|
period.is_current = period.date_start <= today <= period.date_end
|
|
|
|
@api.depends('date_start', 'date_end')
|
|
def _compute_display_order(self):
|
|
"""
|
|
Compute display order for proper dropdown sorting:
|
|
- Current period: 0
|
|
- Future periods: 1-999 (by days from today)
|
|
- Past periods: 1000+ (by days from today, reversed)
|
|
"""
|
|
today = fields.Date.context_today(self)
|
|
for period in self:
|
|
if not period.date_start or not period.date_end:
|
|
period.display_order = 9999
|
|
elif period.date_start <= today <= period.date_end:
|
|
# Current period - top priority
|
|
period.display_order = 0
|
|
elif period.date_start > today:
|
|
# Future period - closer dates have lower order
|
|
days_ahead = (period.date_start - today).days
|
|
period.display_order = min(days_ahead, 999)
|
|
else:
|
|
# Past period - more recent dates have lower order
|
|
days_ago = (today - period.date_end).days
|
|
period.display_order = 1000 + days_ago
|
|
|
|
@api.constrains('date_start', 'date_end')
|
|
def _check_dates(self):
|
|
for period in self:
|
|
if period.date_end < period.date_start:
|
|
raise ValidationError(_('Period end date must be after start date.'))
|
|
# Check for overlaps within same company and schedule type
|
|
overlapping = self.search([
|
|
('id', '!=', period.id),
|
|
('company_id', '=', period.company_id.id),
|
|
('schedule_type', '=', period.schedule_type),
|
|
('date_start', '<=', period.date_end),
|
|
('date_end', '>=', period.date_start),
|
|
])
|
|
if overlapping:
|
|
raise ValidationError(_('Pay periods cannot overlap. Found overlap with: %s') % overlapping[0].name)
|
|
|
|
@api.model
|
|
def generate_periods(self, company_id, schedule_type, start_date, num_periods=12, pay_day_offset=7):
|
|
"""
|
|
Generate pay periods based on schedule type.
|
|
|
|
Args:
|
|
company_id: Company ID
|
|
schedule_type: weekly, biweekly, semi_monthly, monthly
|
|
start_date: First period start date
|
|
num_periods: Number of periods to generate
|
|
pay_day_offset: Days after period end to set pay date
|
|
"""
|
|
periods = []
|
|
current_start = start_date
|
|
|
|
for _ in range(num_periods):
|
|
if schedule_type == 'weekly':
|
|
current_end = current_start + timedelta(days=6)
|
|
next_start = current_start + timedelta(days=7)
|
|
elif schedule_type == 'biweekly':
|
|
current_end = current_start + timedelta(days=13)
|
|
next_start = current_start + timedelta(days=14)
|
|
elif schedule_type == 'semi_monthly':
|
|
if current_start.day <= 15:
|
|
current_end = current_start.replace(day=15)
|
|
next_start = current_start.replace(day=16)
|
|
else:
|
|
# End of month
|
|
next_month = current_start + relativedelta(months=1, day=1)
|
|
current_end = next_month - timedelta(days=1)
|
|
next_start = next_month
|
|
elif schedule_type == 'monthly':
|
|
next_month = current_start + relativedelta(months=1, day=1)
|
|
current_end = next_month - timedelta(days=1)
|
|
next_start = next_month
|
|
else:
|
|
raise ValidationError(_('Unknown schedule type: %s') % schedule_type)
|
|
|
|
pay_date = current_end + timedelta(days=pay_day_offset)
|
|
|
|
# Check if period already exists
|
|
existing = self.search([
|
|
('company_id', '=', company_id),
|
|
('schedule_type', '=', schedule_type),
|
|
('date_start', '=', current_start),
|
|
], limit=1)
|
|
|
|
if not existing:
|
|
period = self.create({
|
|
'company_id': company_id,
|
|
'schedule_type': schedule_type,
|
|
'date_start': current_start,
|
|
'date_end': current_end,
|
|
'pay_date': pay_date,
|
|
})
|
|
periods.append(period)
|
|
|
|
current_start = next_start
|
|
|
|
return periods
|
|
|
|
@api.model
|
|
def get_current_period(self, company_id, schedule_type):
|
|
"""Get the current active pay period."""
|
|
today = fields.Date.context_today(self)
|
|
period = self.search([
|
|
('company_id', '=', company_id),
|
|
('schedule_type', '=', schedule_type),
|
|
('date_start', '<=', today),
|
|
('date_end', '>=', today),
|
|
], limit=1)
|
|
return period
|
|
|
|
@api.model
|
|
def get_available_periods(self, company_id, schedule_type, include_past_months=6, include_future_months=6):
|
|
"""
|
|
Get list of available pay periods for selection.
|
|
Ordered: Current period first, then future periods, then past periods.
|
|
"""
|
|
today = fields.Date.context_today(self)
|
|
|
|
# Calculate date range
|
|
past_date = today - relativedelta(months=include_past_months)
|
|
future_date = today + relativedelta(months=include_future_months)
|
|
|
|
# Get all periods within range
|
|
all_periods = self.search([
|
|
('company_id', '=', company_id),
|
|
('schedule_type', '=', schedule_type),
|
|
('date_start', '>=', past_date),
|
|
('date_end', '<=', future_date),
|
|
])
|
|
|
|
# Separate into current, future, and past
|
|
current = all_periods.filtered(lambda p: p.date_start <= today <= p.date_end)
|
|
future = all_periods.filtered(lambda p: p.date_start > today).sorted('date_start')
|
|
past = all_periods.filtered(lambda p: p.date_end < today).sorted('date_start', reverse=True)
|
|
|
|
# Combine: current first, then future (ascending), then past (descending)
|
|
return current + future + past
|
|
|
|
@api.model
|
|
def auto_generate_periods_if_needed(self, company_id, schedule_type):
|
|
"""
|
|
Automatically generate pay periods for past 6 months and future 6 months if not exist.
|
|
"""
|
|
settings = self.env['payroll.pay.period.settings'].get_or_create_settings(company_id)
|
|
today = fields.Date.context_today(self)
|
|
|
|
# Calculate how far back and forward we need
|
|
past_date = today - relativedelta(months=6)
|
|
future_date = today + relativedelta(months=6)
|
|
|
|
# Find the first period start date aligned to schedule
|
|
first_start = settings.first_period_start
|
|
|
|
# If first_period_start is in the future, work backwards
|
|
if first_start > past_date:
|
|
# Calculate periods going backwards
|
|
periods_to_generate = []
|
|
current_start = first_start
|
|
|
|
# Go backwards to cover past 6 months
|
|
while current_start > past_date:
|
|
if schedule_type == 'weekly':
|
|
current_start = current_start - timedelta(days=7)
|
|
elif schedule_type == 'biweekly':
|
|
current_start = current_start - timedelta(days=14)
|
|
elif schedule_type == 'semi_monthly':
|
|
if current_start.day <= 15:
|
|
# Go to previous month's 16th
|
|
prev_month = current_start - relativedelta(months=1)
|
|
current_start = prev_month.replace(day=16)
|
|
else:
|
|
current_start = current_start.replace(day=1)
|
|
elif schedule_type == 'monthly':
|
|
current_start = current_start - relativedelta(months=1)
|
|
|
|
first_start = current_start
|
|
|
|
# Now generate from that start date forward
|
|
periods_needed = 0
|
|
temp_start = first_start
|
|
while temp_start <= future_date:
|
|
periods_needed += 1
|
|
if schedule_type == 'weekly':
|
|
temp_start = temp_start + timedelta(days=7)
|
|
elif schedule_type == 'biweekly':
|
|
temp_start = temp_start + timedelta(days=14)
|
|
elif schedule_type == 'semi_monthly':
|
|
if temp_start.day <= 15:
|
|
temp_start = temp_start.replace(day=16)
|
|
else:
|
|
temp_start = temp_start + relativedelta(months=1, day=1)
|
|
elif schedule_type == 'monthly':
|
|
temp_start = temp_start + relativedelta(months=1)
|
|
|
|
# Generate the periods
|
|
self.generate_periods(
|
|
company_id=company_id,
|
|
schedule_type=schedule_type,
|
|
start_date=first_start,
|
|
num_periods=periods_needed,
|
|
pay_day_offset=settings.pay_day_offset,
|
|
)
|
|
|
|
|
|
class PayrollPayPeriodSettings(models.Model):
|
|
"""
|
|
Pay Period Settings per Company
|
|
Stores the payroll schedule configuration.
|
|
"""
|
|
_name = 'payroll.pay.period.settings'
|
|
_description = 'Pay Period Settings'
|
|
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
string='Company',
|
|
required=True,
|
|
default=lambda self: self.env.company,
|
|
ondelete='cascade',
|
|
)
|
|
|
|
schedule_type = fields.Selection([
|
|
('weekly', 'Weekly'),
|
|
('biweekly', 'Bi-Weekly'),
|
|
('semi_monthly', 'Semi-Monthly'),
|
|
('monthly', 'Monthly'),
|
|
], string='Pay Schedule', required=True, default='biweekly')
|
|
|
|
first_period_start = fields.Date(
|
|
string='First Period Start',
|
|
required=True,
|
|
help='Start date of the first pay period',
|
|
)
|
|
|
|
pay_day_offset = fields.Integer(
|
|
string='Days Until Pay Date',
|
|
default=7,
|
|
help='Number of days after period end until pay date',
|
|
)
|
|
|
|
auto_generate_periods = fields.Boolean(
|
|
string='Auto-Generate Periods',
|
|
default=True,
|
|
help='Automatically generate future pay periods',
|
|
)
|
|
|
|
periods_to_generate = fields.Integer(
|
|
string='Periods to Generate',
|
|
default=12,
|
|
help='Number of future periods to keep generated',
|
|
)
|
|
|
|
_sql_constraints = [
|
|
('unique_company', 'unique(company_id)', 'Only one pay period settings record per company.'),
|
|
]
|
|
|
|
@api.model
|
|
def get_or_create_settings(self, company_id=None):
|
|
"""Get or create settings for a company."""
|
|
if not company_id:
|
|
company_id = self.env.company.id
|
|
|
|
settings = self.search([('company_id', '=', company_id)], limit=1)
|
|
if not settings:
|
|
# Create with sensible defaults
|
|
today = fields.Date.context_today(self)
|
|
# Default to start of current bi-weekly period (Monday)
|
|
first_start = today - timedelta(days=today.weekday())
|
|
settings = self.create({
|
|
'company_id': company_id,
|
|
'schedule_type': 'biweekly',
|
|
'first_period_start': first_start,
|
|
})
|
|
return settings
|
|
|
|
def action_generate_periods(self):
|
|
"""Generate pay periods based on settings."""
|
|
self.ensure_one()
|
|
periods = self.env['payroll.pay.period'].generate_periods(
|
|
company_id=self.company_id.id,
|
|
schedule_type=self.schedule_type,
|
|
start_date=self.first_period_start,
|
|
num_periods=self.periods_to_generate,
|
|
pay_day_offset=self.pay_day_offset,
|
|
)
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Pay Periods Generated'),
|
|
'message': _('%d pay periods have been generated.') % len(periods),
|
|
'type': 'success',
|
|
},
|
|
}
|