191 lines
6.3 KiB
Python
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)
|