626 lines
22 KiB
Python
626 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
|
|
from odoo import api, fields, models, tools, _
|
|
import ast
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import float_compare, float_is_zero
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
|
|
|
|
# class HrEmployee(models.Model):
|
|
# _inherit='hr.employee'
|
|
# @api.model
|
|
# def action_open_bank_balance_in_gl(self):
|
|
#
|
|
# self.ensure_one()
|
|
# action = self.env["ir.actions.actions"]._for_xml_id("odex25_account_reports.action_account_report_general_ledger")
|
|
# employee = self.browse(self._context.get('active_id'))
|
|
# action['context'] = dict(ast.literal_eval(action['context']), default_filter_accounts=employee.journal_id.default_account_id.code)
|
|
#
|
|
# return action
|
|
|
|
|
|
|
|
|
|
class HrRequestPledge(models.Model):
|
|
_name = 'hr.request.pledge'
|
|
_description = 'Request Pledge'
|
|
_rec_name = "code"
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
|
|
code = fields.Char()
|
|
state = fields.Selection(
|
|
[('draft', _('Draft')),
|
|
('submit', _('Waiting Payroll Officer')),
|
|
('direct_manager', _('Wait HR Department')),
|
|
('hr_manager', _('Wait GM Approval')),
|
|
('executive_manager', _('Wait Transfer')),
|
|
('financial_approve', _('Wait Financial Approval')),
|
|
('pay', _('Transferred')), ('refused', _('Refused')),
|
|
('locked', _('Locked')),
|
|
('closed', _('Loan Suspended'))],
|
|
default="draft", tracking=True)
|
|
date = fields.Date(required=True)
|
|
department_id = fields.Many2one(related='employee_id.department_id', readonly=True, store=True)
|
|
from_hr_depart = fields.Boolean()
|
|
job_id = fields.Many2one(related='employee_id.job_id', readonly=True)
|
|
dashboard_id = fields.Many2one(
|
|
'base.dashbord',
|
|
|
|
index=True
|
|
)
|
|
# is_financial_impact = fields.Boolean(
|
|
# compute='_compute_is_financial_impact',
|
|
# store=True
|
|
# )
|
|
custody_type_id = fields.Many2one(
|
|
'custody.types',
|
|
string='Custody Types',
|
|
required=True,
|
|
tracking=True
|
|
)
|
|
journal_id = fields.Many2one(related='custody_type_id.journal_id', readonly=True)
|
|
|
|
|
|
employee_id = fields.Many2one('hr.employee', 'Employee',
|
|
default=lambda item: item.get_user_id(), index=True)
|
|
|
|
emp_expect_amount = fields.Float(string='Request Employee Amount')
|
|
description = fields.Char("Statement")
|
|
|
|
|
|
currency_id = fields.Many2one(
|
|
'res.currency',
|
|
string='Currency',
|
|
default=lambda self: self.env.company.currency_id
|
|
)
|
|
company_id = fields.Many2one(
|
|
"res.company",
|
|
string="Company",
|
|
default=lambda self: self.env.company,
|
|
required=True,
|
|
readonly=True
|
|
)
|
|
spent_amount = fields.Float(string="Amount Spent", default=0.0)
|
|
remaining_amount = fields.Float(string="Amount Remaining", compute="_compute_remaining_amount", store=True)
|
|
custody_status = fields.Selection([
|
|
('new', 'New'),
|
|
('partial', 'Partial'),
|
|
('paid', 'Paid'),
|
|
('exceeded', 'Exceeded')
|
|
],compute='_compute_custody_status', string='Custody Status', default='new', tracking=True)
|
|
|
|
|
|
payment_ids = fields.One2many(
|
|
'account.payment', 'hr_request_pledge_id',
|
|
string="Payments"
|
|
)
|
|
total_paid_amount = fields.Float(
|
|
string="Total Paid Amount",
|
|
compute='_compute_total_paid_amount',
|
|
store=True
|
|
)
|
|
permanent_pledge = fields.Boolean(
|
|
string="Permanent Pledge",
|
|
default=False,
|
|
)
|
|
spent_amount_computed = fields.Float(
|
|
string="Computed Spent Amount",
|
|
compute="_compute_spent_amount_from_moves",
|
|
store=True,
|
|
help="Automatically distributed from related expense journal entries by employee."
|
|
)
|
|
total_refunded_amount = fields.Float(
|
|
string="Total Refunded Amount",
|
|
compute='_compute_total_refunded_amount',
|
|
store=True
|
|
)
|
|
|
|
@api.depends('payment_ids.amount', 'payment_ids.state', 'payment_ids.payment_type')
|
|
def _compute_total_refunded_amount(self):
|
|
for rec in self:
|
|
# Filter posted inbound payments
|
|
posted_payments = rec.payment_ids.filtered(lambda p: p.state == 'posted')
|
|
inbound_total = sum(posted_payments.filtered(lambda p: p.payment_type == 'inbound').mapped('amount'))
|
|
rec.total_refunded_amount = inbound_total
|
|
def action_lock_pledge(self):
|
|
for record in self:
|
|
if record.remaining_amount > 0:
|
|
raise UserError(_("You cannot close this pledge because there is a remaining amount of %.2f.") % record.remaining_amount)
|
|
record.state = 'locked'
|
|
|
|
@api.depends('employee_id', 'total_paid_amount')
|
|
def _compute_spent_amount_from_moves(self):
|
|
Move = self.env['account.move']
|
|
all_employees = self.mapped('employee_id')
|
|
|
|
for emp in all_employees:
|
|
moves = Move.search([
|
|
('move_type', 'in', ['in_receipt', 'in_invoice']),
|
|
('is_petty_paid', '=', True),
|
|
('state', '=', 'posted'),
|
|
('petty_employee_id', '=', emp.id),
|
|
])
|
|
total_spent = 0.0
|
|
for move in moves:
|
|
total_spent += sum(move.line_ids.filtered(lambda l: l.debit > 0).mapped('debit'))
|
|
pledges = self.search([('employee_id', '=', emp.id), ('state', 'in', ['pay', 'locked'])], order='id asc')
|
|
|
|
remaining = total_spent
|
|
|
|
for pledge in pledges:
|
|
if remaining <= 0:
|
|
pledge.spent_amount_computed = 0.0
|
|
pledge.spent_amount = 0.0
|
|
continue
|
|
|
|
limit = pledge.total_paid_amount or 0.0
|
|
|
|
if remaining >= limit and limit > 0:
|
|
pledge.spent_amount_computed = limit
|
|
pledge.spent_amount = limit
|
|
remaining -= limit
|
|
else:
|
|
pledge.spent_amount_computed = remaining
|
|
pledge.spent_amount = remaining
|
|
remaining = 0
|
|
|
|
if remaining > 0 and pledges:
|
|
pledges[-1].spent_amount_computed += remaining
|
|
pledges[-1].spent_amount += remaining
|
|
|
|
@api.depends('spent_amount', 'total_paid_amount')
|
|
def _compute_custody_status(self):
|
|
for pledge in self:
|
|
remaining = (pledge.total_paid_amount or 0.0) - (pledge.spent_amount or 0.0)
|
|
if remaining > 0:
|
|
pledge.custody_status = 'partial'
|
|
elif remaining == 0:
|
|
pledge.custody_status = 'paid'
|
|
else:
|
|
pledge.custody_status = 'exceeded'
|
|
|
|
# @api.depends('payment_ids.amount', 'payment_ids.state', 'payment_ids.payment_type')
|
|
# def _compute_total_paid_amount(self):
|
|
# for rec in self:
|
|
# posted_outbound_payments = rec.payment_ids.filtered(
|
|
# lambda p: p.state == 'posted' and p.payment_type == 'outbound')
|
|
# rec.total_paid_amount = sum(posted_outbound_payments.mapped('amount'))
|
|
|
|
@api.depends('payment_ids.amount', 'payment_ids.state', 'payment_ids.payment_type')
|
|
def _compute_total_paid_amount(self):
|
|
for rec in self:
|
|
posted_payments = rec.payment_ids.filtered(lambda p: p.state == 'posted')
|
|
|
|
outbound_total = sum(posted_payments.filtered(lambda p: p.payment_type == 'outbound').mapped('amount'))
|
|
|
|
|
|
rec.total_paid_amount = outbound_total
|
|
|
|
@api.depends('spent_amount', 'total_paid_amount')
|
|
def _compute_remaining_amount(self):
|
|
for rec in self:
|
|
rec.remaining_amount = (rec.total_paid_amount or 0.0) - (rec.spent_amount or 0.0) - (rec.total_refunded_amount or 0.0)
|
|
|
|
@api.model
|
|
def search(self, args, offset=0, limit=None, order=None, count=False):
|
|
if not self.env.su:
|
|
user_company_ids = self.env.user.company_ids.ids
|
|
company_domain = [('company_id', 'in', user_company_ids)]
|
|
args = args + company_domain
|
|
return super(HrRequestPledge, self).search(args, offset=offset, limit=limit, order=order, count=count)
|
|
|
|
@api.model
|
|
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
|
|
|
|
if not self.env.context.get('skip_company_check'):
|
|
company_domain = [('company_id', 'in', self.env.user.company_ids.ids)]
|
|
domain = (domain or []) + company_domain
|
|
|
|
return super(HrRequestPledge, self).search_read(
|
|
domain, fields, offset, limit, order
|
|
)
|
|
|
|
@api.model
|
|
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
|
if not self.env.su:
|
|
user_company_ids = self.env.user.company_ids.ids
|
|
company_domain = [('company_id', 'in', user_company_ids)]
|
|
domain = domain + company_domain
|
|
return super(HrRequestPledge, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
|
|
|
|
# def unlink(self):
|
|
# for i in self:
|
|
# if i.state != 'draft':
|
|
# raise UserError(_('You can not delete record in state not in draft'))
|
|
# return super(HrRequestPledge, self).unlink()
|
|
|
|
|
|
@api.model
|
|
def allocate_payment_to_pledges(self, employee_id, amount):
|
|
"""
|
|
Allocate a payment amount to employee's custody requests ordered by oldest.
|
|
Handles partial, full, and exceeded states.
|
|
"""
|
|
print(f"🚀 allocate_payment_to_pledges called | employee_id={employee_id} | amount={amount}")
|
|
|
|
remaining_amount = amount
|
|
|
|
pledges = self.search([
|
|
('employee_id', '=', employee_id),
|
|
('custody_status', 'in', ['partial','new' ]),
|
|
], order="id asc")
|
|
print(f"🔍 pledges found: {pledges}")
|
|
|
|
for pledge in pledges:
|
|
if remaining_amount <= 0:
|
|
break
|
|
|
|
spent = pledge.spent_amount or 0.0
|
|
total = pledge.emp_expect_amount or 0.0
|
|
pledge_remaining = total - spent
|
|
|
|
if pledge_remaining <= 0:
|
|
continue
|
|
|
|
allocated = min(pledge_remaining, remaining_amount)
|
|
pledge.spent_amount = spent + allocated
|
|
|
|
if pledge.spent_amount < total:
|
|
pledge.custody_status = 'partial'
|
|
elif pledge.spent_amount == total:
|
|
pledge.custody_status = 'paid'
|
|
|
|
remaining_amount -= allocated
|
|
|
|
if remaining_amount > 0:
|
|
target_pledge = (pledges.filtered(lambda p: p.custody_status == 'partial') or pledges)[-1:]
|
|
for pledge in target_pledge:
|
|
pledge.spent_amount += remaining_amount
|
|
pledge.custody_status = 'exceeded'
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api.constrains('custody_type_id', 'emp_expect_amount')
|
|
def _check_custody_amount_limit(self):
|
|
for record in self:
|
|
if record.custody_type_id and record.emp_expect_amount:
|
|
if record.emp_expect_amount > record.custody_type_id.max_custody_amount:
|
|
raise ValidationError(_(
|
|
"The requested amount (%s) exceeds the maximum allowed for the selected custody type (%s)."
|
|
) % (record.emp_expect_amount, record.custody_type_id.max_custody_amount))
|
|
|
|
# @api.depends('dashboard_id.pledge_ids.is_financial_impact')
|
|
# def _compute_is_financial_impact(self):
|
|
# for record in self:
|
|
# if record.dashboard_id:
|
|
# record.is_financial_impact = any(
|
|
# pledge.is_financial_impact for pledge in record.dashboard_id.pledge_ids
|
|
# )
|
|
# else:
|
|
# record.is_financial_impact = False
|
|
|
|
# record.is_financial_impact = record.dashboard_id.pledge_ids.is_financial_impact
|
|
|
|
def get_user_id(self):
|
|
employee_id = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1)
|
|
if employee_id:
|
|
return employee_id.id
|
|
else:
|
|
return False
|
|
|
|
|
|
|
|
# @api.model
|
|
# def default_get(self, fields):
|
|
# res = super().default_get(fields)
|
|
# res['journal_id'] = False
|
|
# return res
|
|
|
|
|
|
@api.constrains('emp_expect_amount')
|
|
def _check_positive_emp_expect_amount(self):
|
|
for rec in self:
|
|
if rec.emp_expect_amount <= 0:
|
|
raise ValidationError(
|
|
_("Employee expect amount should be bigger than zero!")
|
|
)
|
|
|
|
@api.model
|
|
def create(self, values):
|
|
|
|
# Auto-link dashboard if not provided
|
|
if not values.get('dashboard_id'):
|
|
dashboard = self.env['base.dashbord'].search([ # Fix typo: 'base.dashboard'
|
|
('model_name', '=', self._name)
|
|
], limit=1)
|
|
if dashboard:
|
|
values['dashboard_id'] = dashboard.id
|
|
|
|
# Generate sequence code (your existing logic)
|
|
seq = self.env['ir.sequence'].next_by_code('hr.request.pledge') or '/'
|
|
values['code'] = seq # Assign the sequence to the 'code' field
|
|
|
|
# Create the record
|
|
return super(HrRequestPledge, self).create(values)
|
|
|
|
def submit(self):
|
|
self.state = "submit"
|
|
|
|
def direct_manager(self):
|
|
self.state = "direct_manager"
|
|
|
|
def hr_manager(self):
|
|
self.state = "hr_manager"
|
|
|
|
def executive_manager(self):
|
|
self.state = "executive_manager"
|
|
|
|
def refused(self):
|
|
self.state = "refused"
|
|
|
|
def cancel(self):
|
|
self.state = "cancel"
|
|
|
|
def action_open_confirm_wizard(self):
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('Confirmation Wizard for Pledge Payment'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'hr.request.pledge.confirm.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_pledge_id': self.id,
|
|
'default_action_type': self._context.get('action_type'),
|
|
},
|
|
}
|
|
|
|
def pay(self):
|
|
|
|
if not self.journal_id:
|
|
raise ValidationError(_('Please set the journal for this employee.'))
|
|
employee_partner = self.employee_id.user_id.partner_id
|
|
if not employee_partner:
|
|
raise ValidationError(_('Employee must have a related partner.'))
|
|
action_type = self.env.context.get('action_type', 'create')
|
|
if action_type == 'feed':
|
|
amount = self.spent_amount or 0.0
|
|
if amount <= 0:
|
|
raise ValidationError(_('Spent amount must be greater than zero for feeding the pledge.'))
|
|
else:
|
|
amount = self.emp_expect_amount or 0.0
|
|
|
|
payment_vals = {
|
|
'payment_type': 'outbound',
|
|
'partner_type': 'supplier',
|
|
'is_internal_transfer': True,
|
|
'skip_paired_payment': True,
|
|
'amount': amount,
|
|
'journal_id': self.journal_id.id,
|
|
'date': fields.datetime.today(),
|
|
'ref': self.description,
|
|
'hr_request_pledge': self.id,
|
|
'hr_request_pledge_id': self.id,
|
|
'partner_id': employee_partner.id,
|
|
'petty_cash_pledge':True
|
|
}
|
|
payment = self.env['account.payment'].create(payment_vals)
|
|
payment.flush()
|
|
payment.refresh()
|
|
|
|
# employee_partner = self.employee_id.user_id.partner_id
|
|
# if employee_partner and payment.move_id:
|
|
# debit_lines = payment.move_id.line_ids.filtered(lambda l: l.debit > 0)
|
|
# if debit_lines:
|
|
# debit_lines.write({'partner_id': employee_partner.id})
|
|
|
|
if action_type == 'create':
|
|
self.state = "pay"
|
|
|
|
if action_type == 'feed':
|
|
message = _("✅ The pledge has been successfully funded with an amount of %.2f.") % amount
|
|
else:
|
|
message = _("✅ A new pledge has been successfully created with an amount of %.2f.") % amount
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Operation Successful'),
|
|
'message': message,
|
|
'type': 'success',
|
|
'sticky': False,
|
|
'next': {'type': 'ir.actions.act_window_close'},
|
|
}
|
|
}
|
|
|
|
def refund_remaining_amount(self):
|
|
for rec in self:
|
|
if rec.remaining_amount <= 0:
|
|
raise ValidationError(_('There is no remaining amount to refund.'))
|
|
if not rec.journal_id:
|
|
raise ValidationError(_('Please set the journal for this employee.'))
|
|
employee_partner = rec.employee_id.user_id.partner_id
|
|
if not employee_partner:
|
|
raise ValidationError(_('Employee must have a related partner.'))
|
|
amount = rec.remaining_amount
|
|
payment_vals = {
|
|
'payment_type': 'inbound',
|
|
'partner_type': 'customer',
|
|
'is_internal_transfer': True,
|
|
'skip_paired_payment': True,
|
|
'amount': amount,
|
|
'journal_id': rec.journal_id.id,
|
|
'date': fields.datetime.today(),
|
|
'ref': _('Refund of pledge: %s') % rec.code,
|
|
'hr_request_pledge_id': rec.id,
|
|
'partner_id': employee_partner.id,
|
|
'petty_cash_pledge': True,
|
|
}
|
|
payment = self.env['account.payment'].create(payment_vals)
|
|
payment.flush()
|
|
payment.refresh()
|
|
message = _("تم إرجاع المبلغ")
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Operation Successful'),
|
|
'message': message,
|
|
'type': 'success',
|
|
'sticky': False,
|
|
'next': {'type': 'ir.actions.act_window_close'},
|
|
}
|
|
}
|
|
|
|
def action_open_refund_payments(self):
|
|
refund_payments = self.env['account.payment'].search([
|
|
'|',
|
|
('hr_request_pledge_id', '=', self.id),
|
|
('hr_request_pledge', '=', self.id),
|
|
('payment_type', '=', 'inbound'),
|
|
])
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Refund Payments',
|
|
'res_model': 'account.payment',
|
|
'domain': [('id', 'in', refund_payments.ids)] if refund_payments else [('id', '=', False)],
|
|
'view_mode': 'tree,form',
|
|
'target': 'current',
|
|
}
|
|
|
|
def financialApproval(self):
|
|
self.state = "financial_approve"
|
|
|
|
|
|
def action_open_payment_confirmation(self):
|
|
"""
|
|
"""
|
|
return {
|
|
'name': _('Confirm Payment'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'hr.request.pledge.pay.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {'default_pledge_id': self.id, 'action_type': self.env.context.get('action_type')}
|
|
}
|
|
|
|
def action_account_payment_budget_pledge(self):
|
|
treeview_ref = self.env.ref('account.view_account_payment_tree', False) # tree view
|
|
formview_ref = self.env.ref('odex25_account_saip.view_account_payment_new_approve_form', False)
|
|
budget_account_payment = self.env['account.payment'].search([
|
|
'|',
|
|
('hr_request_pledge_id', '=', self.id),
|
|
('hr_request_pledge', '=', self.id),
|
|
('payment_type', '=', 'outbound'),
|
|
])
|
|
|
|
if budget_account_payment:
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Achieving Budget',
|
|
'res_model': 'account.payment',
|
|
'domain': [('id', 'in', budget_account_payment.ids)] if budget_account_payment else [],
|
|
'view_mode': 'tree,form',
|
|
'views': [
|
|
(treeview_ref.id, 'tree') if treeview_ref else (False, 'tree'),
|
|
(formview_ref.id, 'form') if formview_ref else (False, 'form'),
|
|
],
|
|
'target': 'current',
|
|
}
|
|
|
|
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = 'account.move'
|
|
|
|
def action_post(self):
|
|
res = super(AccountMove, self).action_post()
|
|
|
|
petty_moves = self.filtered(lambda m:
|
|
m.move_type in ['in_receipt', 'in_invoice'] and
|
|
m.is_petty_paid and
|
|
m.state == 'posted'
|
|
)
|
|
|
|
if petty_moves:
|
|
employees = petty_moves.mapped('petty_employee_id')
|
|
if employees:
|
|
pledges = self.env['hr.request.pledge'].search([
|
|
('employee_id', 'in', employees.ids),
|
|
('state', 'in', ['pay', 'locked'])
|
|
])
|
|
if pledges:
|
|
pledges._compute_spent_amount_from_moves()
|
|
|
|
return res
|
|
|
|
|
|
|
|
class BaseDashboardExtended(models.Model):
|
|
_inherit = 'base.dashbord' # Inherit existing dashboard
|
|
|
|
pledge_ids = fields.One2many(
|
|
'hr.request.pledge',
|
|
'dashboard_id',
|
|
|
|
)
|
|
|
|
|
|
class EmployeeJournal(models.Model):
|
|
_inherit = "hr.employee"
|
|
|
|
journal_id = fields.Many2one('account.journal', domain="[('type', '=', 'cash')]")
|
|
department = fields.Many2one('hr.department', string="Department")
|
|
department_name = fields.Char(
|
|
string='Department Name',
|
|
related='department.name',
|
|
readonly=True,
|
|
store=True,
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class MoveLine(models.Model):
|
|
_inherit = 'account.move.line'
|
|
|
|
@api.model
|
|
def _employee_custody_lines_view(self):
|
|
employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1)
|
|
|
|
if not employee:
|
|
raise UserError("There is no employee associated with this user.")
|
|
|
|
account_id = employee.journal_id.default_account_id.id
|
|
|
|
if not account_id:
|
|
raise UserError("The employee does not have an associated journal or default account.")
|
|
|
|
action = {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Employee Account Report',
|
|
'res_model': 'account.move.line',
|
|
'view_mode': 'tree',
|
|
'view_id': self.env.ref('employee_custody_request.view_account_move_line_tree_custom').id,
|
|
'domain': [('account_id', '=', account_id)],
|
|
'context': {
|
|
},
|
|
'target': 'current',
|
|
}
|
|
return action
|