Initial commit
This commit is contained in:
387
fusion_payroll/models/pay_period.py
Normal file
387
fusion_payroll/models/pay_period.py
Normal file
@@ -0,0 +1,387 @@
|
||||
# -*- 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',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user