191 lines
6.3 KiB
Python

# pyrefly: ignore [missing-import]
from odoo import api, fields, models
# pyrefly: ignore [missing-import]
from odoo.exceptions import ValidationError
class EducationHostelFeeStructure(models.Model):
_name = 'education.hostel.fee.structure'
_description = 'Hostel Fee Structure'
_order = 'hostel_id, room_type'
hostel_id = fields.Many2one(
'education.hostel',
string='Hostel',
required=True,
ondelete='restrict',
index=True,
)
room_type = fields.Selection([
('single', 'Single'),
('double', 'Double'),
('triple', 'Triple'),
('four', 'Four Sharing'),
('dormitory', 'Dormitory'),
('custom', 'Custom'),
], string='Room Type', required=True)
amount = fields.Float(string='Amount', required=True, digits=(10, 2))
billing_period = fields.Selection([
('monthly', 'Monthly'),
('quarterly', 'Quarterly'),
('semester', 'Per Semester'),
('yearly', 'Yearly'),
], string='Billing Period', required=True, default='monthly')
academic_year_id = fields.Many2one(
'education.academic.year',
string='Academic Year',
ondelete='restrict',
index=True,
)
active = fields.Boolean(default=True)
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
index=True,
)
_sql_constraints = [
(
'uniq_fee_structure',
'unique(hostel_id, room_type, billing_period, academic_year_id)',
'A fee structure with this combination (hostel, room type, billing period, academic year) already exists.',
),
]
@api.constrains('amount')
def _check_amount(self):
for rec in self:
if rec.amount <= 0:
raise ValidationError('Fee amount must be greater than zero.')
class EducationHostelFee(models.Model):
"""Hostel fee record per student per billing cycle.
This model is intentionally kept internal (not integrated with accounting).
An extension point `action_integrate_accounting()` is provided for future
accounting integration (e.g., creating account.move entries).
"""
_name = 'education.hostel.fee'
_description = 'Hostel Fee'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'due_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self.env['ir.sequence'].next_by_code('education.hostel.fee'),
index=True,
)
student_id = fields.Many2one(
'education.student',
string='Student',
required=True,
ondelete='restrict',
index=True,
tracking=True,
)
member_id = fields.Many2one(
'education.hostel.member',
string='Hostel Allocation',
ondelete='set null',
index=True,
)
hostel_id = fields.Many2one(
'education.hostel',
string='Hostel',
required=True,
ondelete='restrict',
index=True,
)
fee_structure_id = fields.Many2one(
'education.hostel.fee.structure',
string='Fee Structure',
ondelete='set null',
)
amount = fields.Float(string='Amount', required=True, digits=(10, 2))
due_date = fields.Date(string='Due Date', tracking=True)
paid_date = fields.Date(string='Paid Date', tracking=True)
state = fields.Selection([
('draft', 'Draft'),
('due', 'Due'),
('paid', 'Paid'),
('overdue', 'Overdue'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', required=True, tracking=True, index=True)
notes = fields.Text(string='Notes')
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
index=True,
)
@api.constrains('amount')
def _check_amount(self):
for rec in self:
if rec.amount <= 0:
raise ValidationError('Fee amount must be greater than zero.')
@api.constrains('paid_date', 'due_date')
def _check_dates(self):
for rec in self:
if rec.paid_date and rec.due_date and rec.paid_date < rec.due_date:
pass # Paying before due date is allowed
if rec.paid_date and rec.state not in ('paid',):
pass # allowed — state may lag until action
# ── Workflow ──────────────────────────────────────────────────────────────
def action_set_due(self):
for rec in self:
if rec.state == 'draft':
rec.state = 'due'
def action_mark_paid(self):
for rec in self:
if rec.state not in ('due', 'overdue'):
raise ValidationError("Only due or overdue fees can be marked as paid.")
rec.state = 'paid'
rec.paid_date = fields.Date.today()
rec.message_post(body=f"Fee marked as paid on {rec.paid_date}.")
def action_set_overdue(self):
"""Called by a scheduled action or cron to mark overdue fees."""
today = fields.Date.today()
overdue = self.search([
('state', '=', 'due'),
('due_date', '<', today),
])
overdue.write({'state': 'overdue'})
def action_cancel(self):
self.write({'state': 'cancelled'})
def action_integrate_accounting(self):
"""Extension point for future accounting integration.
When accounting integration is required:
1. Add 'account' to module depends
2. Override this method to create account.move entries
3. Map education.hostel.fee -> account.move.line
"""
raise NotImplementedError(
"Accounting integration is not yet implemented. "
"Override action_integrate_accounting() in a custom module that depends on 'account'."
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = (
self.env['ir.sequence'].next_by_code('education.hostel.fee') or '/'
)
return super().create(vals_list)