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