# pyrefly: ignore [missing-import] import datetime from odoo import api, fields, models # pyrefly: ignore [missing-import] from odoo.exceptions import ValidationError class EducationFeeAssignment(models.Model): _name = 'education.fee.assignment' _description = 'Student Fee Invoice / Assignment' _order = 'assignment_date desc, id desc' name = fields.Char(string='Invoice Number', copy=False, readonly=True, default='/', index=True) student_id = fields.Many2one('education.student', string='Student', required=True, index=True) academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True, index=True) academic_term_id = fields.Many2one('education.academic.term', string='Academic Term', index=True) semester_id = fields.Many2one('education.semester', string='Semester', required=True, index=True) fee_structure_id = fields.Many2one('education.fee.structure', string='Fee Structure', required=True, index=True) assignment_date = fields.Date(string='Assignment Date', required=True, default=fields.Date.context_today) due_date = fields.Date( string='Due Date', required=True, default=fields.Date.context_today, index=True ) state = fields.Selection([ ('draft', 'Draft'), ('confirmed', 'Confirmed'), ], string='Status', default='draft', required=True, index=True) currency_id = fields.Many2one('res.currency', related='fee_structure_id.currency_id', store=True) line_ids = fields.One2many(related='fee_structure_id.line_ids', string='Fee Lines', readonly=True) payment_ids = fields.One2many('education.student.payment', 'fee_assignment_id', string='Payments') amount = fields.Monetary( string='Total Invoiced Amount', compute='_compute_amounts', store=True, currency_field='currency_id' ) amount_paid = fields.Monetary( string='Amount Paid', compute='_compute_amounts', store=True, currency_field='currency_id' ) amount_due = fields.Monetary( string='Amount Due', compute='_compute_amounts', store=True, currency_field='currency_id' ) payment_status = fields.Selection([ ('unpaid', 'Unpaid'), ('partial', 'Partially Paid'), ('paid', 'Paid'), ('overdue', 'Overdue'), ], string='Payment Status', compute='_compute_amounts', store=True, 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, ) @api.onchange('fee_structure_id', 'assignment_date') def _onchange_fee_structure(self): if self.fee_structure_id and self.assignment_date: days = getattr(self.fee_structure_id, 'due_days', 30) or 30 self.due_date = self.assignment_date + datetime.timedelta(days=days) @api.constrains('student_id', 'academic_year_id', 'semester_id', 'fee_structure_id', 'company_id') def _check_unique_assignment(self): for rec in self: domain = [ ('id', '!=', rec.id), ('student_id', '=', rec.student_id.id), ('academic_year_id', '=', rec.academic_year_id.id), ('semester_id', '=', rec.semester_id.id), ('fee_structure_id', '=', rec.fee_structure_id.id), ('company_id', '=', rec.company_id.id), ('active', '=', True), ] if self.search_count(domain) > 0: raise ValidationError("This fee structure is already assigned to this student for this semester.") @api.depends( 'fee_structure_id.line_ids.amount', 'payment_ids.amount', 'payment_ids.status', 'due_date', 'state' ) def _compute_amounts(self): today = fields.Date.today() for rec in self: total = sum(line.amount for line in rec.fee_structure_id.line_ids) if rec.fee_structure_id else 0.0 rec.amount = total confirmed_payments = rec.payment_ids.filtered(lambda p: p.status == 'confirmed') paid = sum(p.amount for p in confirmed_payments) # Check if student's overall dues are fully cleared and no specific allocation was attached if paid == 0 and total > 0 and rec.student_id.outstanding_amount == 0 and rec.student_id.total_paid >= total: paid = total rec.amount_paid = paid rec.amount_due = max(0.0, total - paid) if rec.state != 'confirmed': rec.payment_status = 'unpaid' elif total == 0 or rec.amount_due <= 0: rec.payment_status = 'paid' elif rec.due_date and rec.due_date < today: rec.payment_status = 'overdue' elif paid > 0: rec.payment_status = 'partial' else: rec.payment_status = 'unpaid' def action_confirm(self): for rec in self: if rec.state == 'confirmed': continue if not rec.fee_structure_id.line_ids: raise ValidationError(f"The selected fee structure '{rec.fee_structure_id.name}' has no fee lines. Please add fee lines to this structure before confirming.") if not rec.name or rec.name == '/': seq = self.env['ir.sequence'].next_by_code('education.fee.assignment') rec.name = seq or f"INV/{rec.id:05d}" for line in rec.fee_structure_id.line_ids: self.env['education.student.ledger'].create({ 'student_id': rec.student_id.id, 'academic_year_id': rec.academic_year_id.id, 'semester_id': rec.semester_id.id, 'category_id': line.fee_type_id.category_id.id, 'fee_type_id': line.fee_type_id.id, 'date': rec.assignment_date, 'ref': rec.name, 'description': f"{line.fee_type_id.name} (Invoice: {rec.name}, Structure: {rec.fee_structure_id.name})", 'debit': line.amount, 'credit': 0.0, 'status': 'outstanding', 'company_id': rec.company_id.id, }) rec.state = 'confirmed' def action_cancel(self): for rec in self: if rec.state == 'confirmed': ledger_entries = self.env['education.student.ledger'].search([ ('student_id', '=', rec.student_id.id), ('ref', '=', rec.name), ]) ledger_entries.unlink() rec.state = 'draft' @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.fee.assignment') or '/' if not vals.get('due_date') and vals.get('assignment_date'): vals['due_date'] = fields.Date.from_string(vals['assignment_date']) + datetime.timedelta(days=30) return super().create(vals_list)