Files
tradon/modules/purchase_trade/credit_risk.py
2026-01-07 16:17:21 +01:00

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