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