334 lines
12 KiB
Python
334 lines
12 KiB
Python
from trytond.model import ModelView, ModelSQL, fields
|
|
from trytond.pool import Pool, PoolMeta
|
|
from trytond.pyson import Eval
|
|
from trytond.transaction import Transaction
|
|
from trytond.model import Workflow
|
|
from trytond.report import Report
|
|
from datetime import date, timedelta
|
|
|
|
__all__ = ['Party', 'CreditRiskRule', 'CreditRiskReport']
|
|
__metaclass__ = PoolMeta
|
|
|
|
class Party(metaclass=PoolMeta):
|
|
"Extend party with credit risk fields"
|
|
__name__ = 'party.party'
|
|
|
|
credit_limit = fields.Numeric('Credit Limit', digits=(16, 2))
|
|
credit_currency = fields.Many2One('currency.currency', 'Credit Currency')
|
|
credit_exposure = fields.Function(
|
|
fields.Numeric('Credit Exposure', digits=(16, 2)),
|
|
'get_credit_exposure'
|
|
)
|
|
aging_0_30 = fields.Function(
|
|
fields.Numeric('Aging 0-30', digits=(16, 2)),
|
|
'get_aging_buckets'
|
|
)
|
|
aging_31_60 = fields.Function(
|
|
fields.Numeric('Aging 31-60', digits=(16, 2)),
|
|
'get_aging_buckets'
|
|
)
|
|
aging_61_90 = fields.Function(
|
|
fields.Numeric('Aging 61-90', digits=(16, 2)),
|
|
'get_aging_buckets'
|
|
)
|
|
aging_90p = fields.Function(
|
|
fields.Numeric('Aging >90', digits=(16, 2)),
|
|
'get_aging_buckets'
|
|
)
|
|
utilization = fields.Function(
|
|
fields.Numeric('Utilization %', digits=(5, 2)),
|
|
'get_utilization'
|
|
)
|
|
risk_score = fields.Function(
|
|
fields.Integer('Risk Score'),
|
|
'get_risk_score'
|
|
)
|
|
risk_level = fields.Selection([
|
|
(None,''),
|
|
('low', 'Low'),
|
|
('medium', 'Medium'),
|
|
('high', 'High'),
|
|
], 'Risk Level')
|
|
|
|
acceptable_currencies = fields.One2Many(
|
|
'party.acceptable.currency', 'party',
|
|
'Acceptable Currencies'
|
|
)
|
|
|
|
payment_conditions = fields.One2Many(
|
|
'party.payment.condition', 'party',
|
|
'Payment Conditions'
|
|
)
|
|
|
|
internal_limit = fields.One2Many(
|
|
'party.internal.limit', 'party',
|
|
'Internal limits'
|
|
)
|
|
|
|
insurance_limit = fields.One2Many(
|
|
'party.insurance.limit', 'party',
|
|
'Insurance limits'
|
|
)
|
|
|
|
# -------------------------
|
|
# Calculations
|
|
# -------------------------
|
|
def get_credit_exposure(self, name):
|
|
"""
|
|
Sum of open receivable move lines amounts for this party.
|
|
We take account.move.line where account.kind = 'receivable' and
|
|
reconciliation is None (i.e. open).
|
|
"""
|
|
MoveLine = Pool().get('account.move.line')
|
|
# Search open receivable lines for this party
|
|
lines = MoveLine.search([
|
|
('party', '=', self.id),
|
|
#('account.kind', '=', 'receivable'),
|
|
('reconciliation', '=', None),
|
|
])
|
|
amount = sum([l.amount or 0 for l in lines])
|
|
return amount
|
|
|
|
def get_aging_buckets(self, name):
|
|
"""
|
|
Returns a tuple (a0, a1, a2, a3) corresponding to the bucket requested.
|
|
We'll compute all buckets then return the requested field.
|
|
"""
|
|
MoveLine = Pool().get('account.move.line')
|
|
today = date.today()
|
|
lines = MoveLine.search([
|
|
('party', '=', self.id),
|
|
#('account.kind', '=', 'receivable'),
|
|
('reconciliation', '=', None),
|
|
])
|
|
a0 = a1 = a2 = a3 = 0
|
|
for l in lines:
|
|
# Try to use 'due_date' if present, else 'date'
|
|
line_date = getattr(l, 'due_date', None) or getattr(l, 'date', None) or today
|
|
if not line_date:
|
|
line_date = today
|
|
days = (today - line_date).days
|
|
amt = l.amount or 0
|
|
if days <= 30:
|
|
a0 += amt
|
|
elif days <= 60:
|
|
a1 += amt
|
|
elif days <= 90:
|
|
a2 += amt
|
|
else:
|
|
a3 += amt
|
|
|
|
if name == 'aging_0_30':
|
|
return a0
|
|
if name == 'aging_31_60':
|
|
return a1
|
|
if name == 'aging_61_90':
|
|
return a2
|
|
if name == 'aging_90p':
|
|
return a3
|
|
return 0
|
|
|
|
def get_utilization(self, name):
|
|
limit_ = self.credit_limit or 0
|
|
exposure = self.get_credit_exposure('credit_exposure')
|
|
if not limit_ or limit_ == 0:
|
|
return 0
|
|
util = (exposure / limit_) * 100
|
|
return float(round(util, 2))
|
|
|
|
def get_risk_score(self, name):
|
|
"""
|
|
Compute a simple risk score:
|
|
- base on utilization and >90 days exposure
|
|
- more sophisticated logic can be plugged via CreditRiskRule.
|
|
"""
|
|
score = 0
|
|
util = self.get_utilization('utilization') # percentage
|
|
if util > 100:
|
|
score += 50
|
|
elif util > 80:
|
|
score += 30
|
|
elif util > 50:
|
|
score += 10
|
|
|
|
overdue = self.get_aging_buckets('aging_90p')
|
|
if overdue > 0:
|
|
# scale by overdue relative to limit
|
|
limit = self.credit_limit or 1
|
|
score += int(min(40, (float(overdue) / float(limit)) * 100))
|
|
|
|
# cap
|
|
if score > 100:
|
|
score = 100
|
|
# derive level
|
|
if score >= 70:
|
|
level = 'high'
|
|
elif score >= 30:
|
|
level = 'medium'
|
|
else:
|
|
level = 'low'
|
|
|
|
# ensure we persist risk_level locally (not stored field; update if present)
|
|
try:
|
|
# If we want to store it persistently, we would need a stored field
|
|
self.risk_level = level
|
|
except Exception:
|
|
pass
|
|
|
|
return int(score)
|
|
|
|
class CreditRiskRule(ModelSQL, ModelView):
|
|
"""
|
|
Simple rules table to tweak scoring thresholds.
|
|
Example usage: company-specific thresholds.
|
|
"""
|
|
__name__ = 'credit.risk.rule'
|
|
|
|
name = fields.Char('Name', required=True)
|
|
company = fields.Many2One('company.company', 'Company')
|
|
max_utilization_ok = fields.Float('Max Utilization OK', digits=(5, 2))
|
|
warn_utilization = fields.Float('Warn Utilization', digits=(5, 2))
|
|
block_on_over_limit = fields.Boolean('Block on Over Limit')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class PartyInternalLimit(ModelSQL, ModelView):
|
|
"Party Internal Limit"
|
|
__name__ = 'party.internal.limit'
|
|
|
|
party = fields.Many2One('party.party', 'Party', required=True)
|
|
amount = fields.Numeric('Amount', digits=(16, 2), required=True)
|
|
currency = fields.Many2One('currency.currency', 'Currency', required=True)
|
|
date_from = fields.Date('From', required=True)
|
|
date_to = fields.Date('To', required=True)
|
|
remarks = fields.Char('Remarks')
|
|
|
|
class PartyInsuranceLimit(ModelSQL, ModelView):
|
|
"Party Insurance Limit"
|
|
__name__ = 'party.insurance.limit'
|
|
|
|
party = fields.Many2One('party.party', 'Party', required=True)
|
|
amount = fields.Numeric('Amount', digits=(16, 2))
|
|
currency = fields.Many2One('currency.currency', 'Currency')
|
|
date_from = fields.Date('From')
|
|
date_to = fields.Date('To')
|
|
insurer = fields.Char('Insurance')
|
|
policy = fields.Char('Policy')
|
|
|
|
class PartyPaymentCondition(ModelSQL, ModelView):
|
|
"Party Payment Condition"
|
|
__name__ = 'party.payment.condition'
|
|
|
|
party = fields.Many2One('party.party', 'Party', required=True)
|
|
payment_term = fields.Many2One('account.invoice.payment_term','Payment Term', required=True)
|
|
remaining_risk = fields.Numeric(
|
|
'Remaining Risk %', digits=(5, 2)
|
|
)
|
|
remarks = fields.Text('Remarks')
|
|
|
|
class PartyAcceptableCurrency(ModelSQL, ModelView):
|
|
"Party Acceptable Currency"
|
|
__name__ = 'party.acceptable.currency'
|
|
|
|
party = fields.Many2One(
|
|
'party.party', 'Party', required=True, ondelete='CASCADE'
|
|
)
|
|
currency = fields.Many2One(
|
|
'currency.currency', 'Currency', required=True
|
|
)
|
|
haircut = fields.Numeric('Haircut %', digits=(5, 2))
|
|
|
|
# class CreditRiskReport(ModelView):
|
|
# """
|
|
# A report model (non stored) to expose a table-query-like result via context.
|
|
# The get_context method returns 'rows' which is a list of dicts ready for templates.
|
|
# """
|
|
# __name__ = 'party.credit_risk_report'
|
|
|
|
# start_date = fields.Date('Start Date')
|
|
# end_date = fields.Date('End Date')
|
|
# company = fields.Many2One('company.company', 'Company')
|
|
|
|
# @classmethod
|
|
# def get_report_data(cls, start=None, end=None, company=None):
|
|
# """
|
|
# Return aggregated rows:
|
|
# - party_id, name, credit_limit, exposure, utilization, aging buckets, risk_score, risk_level, status
|
|
# We'll fetch parties with at least some receivable or credit limit set.
|
|
# """
|
|
# Party = Pool().get('party.party')
|
|
# MoveLine = Pool().get('account.move.line')
|
|
|
|
# # build parties list: those with credit limit or receivable lines
|
|
# parties = Party.search([
|
|
# ('credit_limit', '!=', None)
|
|
# ])
|
|
# # also include parties with receivable move lines
|
|
# ml_parties = MoveLine.search([('account.kind', '=', 'receivable')])
|
|
# ml_party_ids = set([l.party.id for l in ml_parties if l.party])
|
|
# for pid in ml_party_ids:
|
|
# try:
|
|
# p = Party(pid)
|
|
# if p not in parties:
|
|
# parties.append(p)
|
|
# except Exception:
|
|
# pass
|
|
|
|
# rows = []
|
|
# for p in parties:
|
|
# exposure = p.get_credit_exposure('credit_exposure')
|
|
# a0 = p.get_aging_buckets('aging_0_30')
|
|
# a1 = p.get_aging_buckets('aging_31_60')
|
|
# a2 = p.get_aging_buckets('aging_61_90')
|
|
# a3 = p.get_aging_buckets('aging_90p')
|
|
# utilization = p.get_utilization('utilization')
|
|
# score = p.get_risk_score('risk_score')
|
|
# level = getattr(p, 'risk_level', None) or (
|
|
# 'high' if score >= 70 else ('medium' if score >= 30 else 'low')
|
|
# )
|
|
# status = 'OK'
|
|
# if p.credit_limit and exposure > p.credit_limit:
|
|
# status = 'OVER_LIMIT'
|
|
|
|
# rows.append({
|
|
# 'party_id': p.id,
|
|
# 'party_name': p.name,
|
|
# 'credit_limit': p.credit_limit or 0,
|
|
# 'exposure': exposure,
|
|
# 'aging_0_30': a0,
|
|
# 'aging_31_60': a1,
|
|
# 'aging_61_90': a2,
|
|
# 'aging_90p': a3,
|
|
# 'utilization_pct': utilization,
|
|
# 'risk_score': score,
|
|
# 'risk_level': level,
|
|
# 'status': status,
|
|
# })
|
|
# # Optionally sort by risk_score desc
|
|
# rows = sorted(rows, key=lambda r: r['risk_score'], reverse=True)
|
|
# return rows
|
|
|
|
# @classmethod
|
|
# def get_context(cls, records, data):
|
|
# """
|
|
# Called by Report action to supply context.
|
|
# We will return dict with 'rows' and some summary aggregates.
|
|
# """
|
|
# start = data.get('start_date') if data else None
|
|
# end = data.get('end_date') if data else None
|
|
# company = data.get('company') if data else None
|
|
|
|
# rows = cls.get_report_data(start=start, end=end, company=company)
|
|
# total_limit = sum([r['credit_limit'] for r in rows])
|
|
# total_exposure = sum([r['exposure'] for r in rows])
|
|
# over_limit = [r for r in rows if r['status'] == 'OVER_LIMIT']
|
|
# ctx = {
|
|
# 'rows': rows,
|
|
# 'total_limit': total_limit,
|
|
# 'total_exposure': total_exposure,
|
|
# 'count_over_limit': len(over_limit),
|
|
# 'image_path': '/mnt/data/f9976760-c385-48ed-b51c-fa5868a0f0ab.png', # optional
|
|
# }
|
|
# return ctx
|