Files
Odoo-Modules/fusion_payroll/models/pay_period.py
2026-02-22 01:22:18 -05:00

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',
},
}