Trade Finance + Other changes
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# This file is part of Tradon. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
from trytond.model import ModelSQL, ModelView, fields
|
||||
from trytond.model import ModelSQL, ModelView, fields, tree
|
||||
from trytond.model import sequence_ordered
|
||||
from trytond.pool import Pool
|
||||
from trytond.pyson import Eval, Bool
|
||||
@@ -184,7 +184,7 @@ class FacilityCovenant(ModelSQL, ModelView):
|
||||
# Facility Limit (Global Limit + Sub-Limits in one model)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FacilityLimit(ModelSQL, ModelView):
|
||||
class FacilityLimit(tree(), ModelSQL, ModelView):
|
||||
'Facility Limit'
|
||||
__name__ = 'trade_finance.facility_limit'
|
||||
_rec_name = 'name'
|
||||
@@ -194,24 +194,41 @@ class FacilityLimit(ModelSQL, ModelView):
|
||||
states={'readonly': Bool(Eval('parent'))},
|
||||
depends=['parent'])
|
||||
parent = fields.Many2One('trade_finance.facility_limit', 'Parent Limit',
|
||||
left='left', right='right',
|
||||
ondelete='RESTRICT',
|
||||
domain=[('facility', '=', Eval('facility'))],
|
||||
depends=['facility'],
|
||||
help='Leave empty for Global Limit (root node)')
|
||||
children = fields.One2Many('trade_finance.facility_limit', 'parent',
|
||||
'Sub-Limits')
|
||||
left = fields.Integer('Left', required=True)
|
||||
right = fields.Integer('Right', required=True)
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
alternative_name = fields.Char('Limit Alternative Name')
|
||||
financing_type = fields.Many2One('trade_finance.financing_type',
|
||||
'Financing Type', ondelete='RESTRICT')
|
||||
currency = fields.Many2One('currency.currency', 'Currency',
|
||||
ondelete='RESTRICT',
|
||||
states={'readonly': Bool(Eval('parent'))},
|
||||
depends=['parent'],
|
||||
help='Currency of the global limit. Sub-limits inherit it from '
|
||||
'their parent.')
|
||||
amount = fields.Numeric('Amount', digits=(16, 2), required=True)
|
||||
tenor = fields.Integer('Tenor (days)',
|
||||
help='Maximum duration of financing from drawdown to repayment')
|
||||
date_from = fields.Date('Valid From')
|
||||
date_to = fields.Date('Valid To')
|
||||
sequence = fields.Integer('Sequence')
|
||||
notes = fields.Text('Notes')
|
||||
|
||||
_order = [('sequence', 'ASC'), ('id', 'ASC')]
|
||||
|
||||
@classmethod
|
||||
def __register__(cls, module_name):
|
||||
super().__register__(module_name)
|
||||
cls._rebuild_tree('parent', None, 0)
|
||||
|
||||
haircuts = fields.One2Many('trade_finance.facility_limit_haircut', 'limit',
|
||||
'Haircuts')
|
||||
currencies = fields.One2Many('trade_finance.facility_limit_currency',
|
||||
@@ -230,6 +247,38 @@ class FacilityLimit(ModelSQL, ModelView):
|
||||
def default_sequence():
|
||||
return 10
|
||||
|
||||
@staticmethod
|
||||
def default_left():
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def default_right():
|
||||
return 0
|
||||
|
||||
@fields.depends('facility', 'currency', 'date_from', 'date_to', 'parent',
|
||||
'_parent_parent.facility', '_parent_parent.currency',
|
||||
'_parent_parent.date_from', '_parent_parent.date_to')
|
||||
def on_change_parent(self):
|
||||
if self.parent:
|
||||
self.facility = self.parent.facility
|
||||
self.currency = self.parent.currency
|
||||
if not self.date_from:
|
||||
self.date_from = self.parent.date_from
|
||||
if not self.date_to:
|
||||
self.date_to = self.parent.date_to
|
||||
|
||||
@fields.depends('facility', 'currency', 'date_from', 'date_to', 'parent',
|
||||
'_parent_facility.currency', '_parent_facility.date_from',
|
||||
'_parent_facility.date_to')
|
||||
def on_change_facility(self):
|
||||
if self.facility and not self.parent:
|
||||
if not self.currency:
|
||||
self.currency = self.facility.currency
|
||||
if not self.date_from:
|
||||
self.date_from = self.facility.date_from
|
||||
if not self.date_to:
|
||||
self.date_to = self.facility.date_to
|
||||
|
||||
@classmethod
|
||||
def create(cls, vlist):
|
||||
vlist = [v.copy() for v in vlist]
|
||||
@@ -237,13 +286,73 @@ class FacilityLimit(ModelSQL, ModelView):
|
||||
if values.get('parent') and not values.get('facility'):
|
||||
parent = cls(values['parent'])
|
||||
values['facility'] = parent.facility.id
|
||||
if values.get('parent'):
|
||||
parent = cls(values['parent'])
|
||||
values['currency'] = (
|
||||
parent.currency.id if parent.currency else None)
|
||||
values.setdefault('date_from', parent.date_from)
|
||||
values.setdefault('date_to', parent.date_to)
|
||||
elif values.get('facility'):
|
||||
facility = Pool().get('trade_finance.facility')(
|
||||
values['facility'])
|
||||
values.setdefault('currency',
|
||||
facility.currency.id if facility.currency else None)
|
||||
values.setdefault('date_from', facility.date_from)
|
||||
values.setdefault('date_to', facility.date_to)
|
||||
return super().create(vlist)
|
||||
|
||||
@classmethod
|
||||
def write(cls, *args):
|
||||
args = list(args)
|
||||
actions = iter(args)
|
||||
new_args = []
|
||||
check_descendants = []
|
||||
sync_currency = []
|
||||
for limits, values in zip(actions, actions):
|
||||
values = values.copy()
|
||||
if values.get('parent'):
|
||||
parent = cls(values['parent'])
|
||||
values['facility'] = parent.facility.id
|
||||
values['currency'] = (
|
||||
parent.currency.id if parent.currency else None)
|
||||
if {'parent', 'currency'} & set(values):
|
||||
sync_currency.extend(limits)
|
||||
if {'parent', 'currency', 'date_from', 'date_to'} & set(values):
|
||||
check_descendants.extend(limits)
|
||||
new_args.extend((limits, values))
|
||||
result = super().write(*new_args)
|
||||
cls._sync_descendant_currencies(sync_currency)
|
||||
cls._check_descendants(check_descendants)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _sync_descendant_currencies(cls, limits):
|
||||
for limit in limits:
|
||||
limit = cls(limit.id)
|
||||
if not limit.currency:
|
||||
continue
|
||||
descendants = cls.search([('parent', 'child_of', [limit.id])])
|
||||
if descendants:
|
||||
super(FacilityLimit, cls).write(
|
||||
descendants, {'currency': limit.currency.id})
|
||||
|
||||
@classmethod
|
||||
def _check_descendants(cls, limits):
|
||||
descendants = []
|
||||
for limit in limits:
|
||||
descendants.extend(
|
||||
cls.search([('parent', 'child_of', [limit.id])]))
|
||||
if descendants:
|
||||
cls.validate(descendants)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, limits):
|
||||
super().validate(limits)
|
||||
for limit in limits:
|
||||
limit.check_single_root()
|
||||
limit.check_required_values()
|
||||
limit.check_parent_consistency()
|
||||
limit.check_dates_within_parent()
|
||||
limit.check_amount_vs_parent()
|
||||
limit.check_children_amounts()
|
||||
|
||||
@@ -260,6 +369,49 @@ class FacilityLimit(ModelSQL, ModelView):
|
||||
f"Limit ('{roots[0].name}'). Only one root limit is "
|
||||
f"allowed per facility.")
|
||||
|
||||
def check_required_values(self):
|
||||
if not self.currency:
|
||||
raise UserError(
|
||||
f"Limit '{self.name}' must define or inherit a currency.")
|
||||
if not self.date_from or not self.date_to:
|
||||
raise UserError(
|
||||
f"Limit '{self.name}' must define valid from and valid to "
|
||||
f"dates.")
|
||||
if self.date_from > self.date_to:
|
||||
raise UserError(
|
||||
f"Limit '{self.name}' valid from date cannot be after "
|
||||
f"its valid to date.")
|
||||
|
||||
def check_parent_consistency(self):
|
||||
if not self.parent:
|
||||
return
|
||||
if self.facility != self.parent.facility:
|
||||
raise UserError(
|
||||
f"Sub-limit '{self.name}' must belong to the same facility "
|
||||
f"as parent limit '{self.parent.name}'.")
|
||||
if self.currency != self.parent.currency:
|
||||
raise UserError(
|
||||
f"Sub-limit '{self.name}' must use the same currency as "
|
||||
f"parent limit '{self.parent.name}'.")
|
||||
|
||||
def check_dates_within_parent(self):
|
||||
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||
raise UserError(
|
||||
f"Limit '{self.name}' valid from date cannot be after its "
|
||||
f"valid to date.")
|
||||
if not self.parent:
|
||||
return
|
||||
if (self.date_from and self.parent.date_from
|
||||
and self.date_from < self.parent.date_from):
|
||||
raise UserError(
|
||||
f"Sub-limit '{self.name}' valid from date cannot be before "
|
||||
f"parent limit '{self.parent.name}' valid from date.")
|
||||
if (self.date_to and self.parent.date_to
|
||||
and self.date_to > self.parent.date_to):
|
||||
raise UserError(
|
||||
f"Sub-limit '{self.name}' valid to date cannot be after "
|
||||
f"parent limit '{self.parent.name}' valid to date.")
|
||||
|
||||
def check_amount_vs_parent(self):
|
||||
if self.parent and self.amount > self.parent.amount:
|
||||
raise UserError(
|
||||
|
||||
2
modules/trade_finance/tests/__init__.py
Normal file
2
modules/trade_finance/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file is part of Tradon. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
117
modules/trade_finance/tests/test_module.py
Normal file
117
modules/trade_finance/tests/test_module.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# This file is part of Tradon. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from trytond.exceptions import UserError
|
||||
from trytond.modules.currency.tests import create_currency
|
||||
from trytond.pool import Pool
|
||||
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
|
||||
|
||||
|
||||
class TradeFinanceTestCase(ModuleTestCase):
|
||||
'Test Trade Finance module'
|
||||
module = 'trade_finance'
|
||||
|
||||
def create_facility(self):
|
||||
pool = Pool()
|
||||
Bank = pool.get('bank')
|
||||
Facility = pool.get('trade_finance.facility')
|
||||
Party = pool.get('party.party')
|
||||
|
||||
currency = create_currency('USD')
|
||||
party = Party(name='Test Bank')
|
||||
party.save()
|
||||
bank = Bank(party=party)
|
||||
bank.save()
|
||||
facility = Facility(
|
||||
name='Test Facility',
|
||||
tfe=bank,
|
||||
currency=currency,
|
||||
commitment_status='committed',
|
||||
date_from=date(2026, 1, 1),
|
||||
date_to=date(2026, 12, 31))
|
||||
facility.save()
|
||||
return facility, currency
|
||||
|
||||
@with_transaction()
|
||||
def test_sublimit_inherits_currency_and_dates(self):
|
||||
'Test sublimit inherits currency and dates from parent'
|
||||
pool = Pool()
|
||||
Limit = pool.get('trade_finance.facility_limit')
|
||||
|
||||
facility, currency = self.create_facility()
|
||||
root, = Limit.create([{
|
||||
'facility': facility.id,
|
||||
'name': 'Global limit',
|
||||
'currency': currency.id,
|
||||
'amount': Decimal('100.00'),
|
||||
'date_from': date(2026, 1, 1),
|
||||
'date_to': date(2026, 12, 31),
|
||||
}])
|
||||
child, = Limit.create([{
|
||||
'parent': root.id,
|
||||
'name': 'Trading limit',
|
||||
'amount': Decimal('50.00'),
|
||||
}])
|
||||
|
||||
self.assertEqual(child.facility, facility)
|
||||
self.assertEqual(child.currency, currency)
|
||||
self.assertEqual(child.date_from, root.date_from)
|
||||
self.assertEqual(child.date_to, root.date_to)
|
||||
|
||||
@with_transaction()
|
||||
def test_sublimit_dates_must_stay_within_parent(self):
|
||||
'Test sublimit dates stay within parent dates'
|
||||
pool = Pool()
|
||||
Limit = pool.get('trade_finance.facility_limit')
|
||||
|
||||
facility, currency = self.create_facility()
|
||||
root, = Limit.create([{
|
||||
'facility': facility.id,
|
||||
'name': 'Global limit',
|
||||
'currency': currency.id,
|
||||
'amount': Decimal('100.00'),
|
||||
'date_from': date(2026, 1, 1),
|
||||
'date_to': date(2026, 12, 31),
|
||||
}])
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
Limit.create([{
|
||||
'parent': root.id,
|
||||
'name': 'Late limit',
|
||||
'amount': Decimal('50.00'),
|
||||
'date_from': date(2026, 1, 1),
|
||||
'date_to': date(2027, 1, 1),
|
||||
}])
|
||||
|
||||
@with_transaction()
|
||||
def test_currency_change_cascades_to_sublimits(self):
|
||||
'Test root currency changes cascade to sublimits'
|
||||
pool = Pool()
|
||||
Limit = pool.get('trade_finance.facility_limit')
|
||||
|
||||
facility, currency = self.create_facility()
|
||||
other_currency = create_currency('EUR')
|
||||
root, = Limit.create([{
|
||||
'facility': facility.id,
|
||||
'name': 'Global limit',
|
||||
'currency': currency.id,
|
||||
'amount': Decimal('100.00'),
|
||||
'date_from': date(2026, 1, 1),
|
||||
'date_to': date(2026, 12, 31),
|
||||
}])
|
||||
child, = Limit.create([{
|
||||
'parent': root.id,
|
||||
'name': 'Trading limit',
|
||||
'amount': Decimal('50.00'),
|
||||
}])
|
||||
|
||||
Limit.write([root], {'currency': other_currency.id})
|
||||
child = Limit(child.id)
|
||||
|
||||
self.assertEqual(child.currency, other_currency)
|
||||
|
||||
|
||||
del ModuleTestCase
|
||||
@@ -30,9 +30,10 @@
|
||||
<field name="description" colspan="5"/>
|
||||
</group>
|
||||
<notebook colspan="6">
|
||||
<page string="Limits" id="limits">
|
||||
<page string="Global Limit" id="limits">
|
||||
<field name="limits" colspan="6"
|
||||
domain="[('parent', '=', None)]"/>
|
||||
domain="[('parent', '=', None)]"
|
||||
view_ids="trade_finance.facility_limit_view_tree,trade_finance.facility_limit_view_form"/>
|
||||
</page>
|
||||
<page string="Currencies" id="currencies">
|
||||
<field name="currencies" colspan="6"/>
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
<field name="alternative_name" colspan="3"/>
|
||||
<label name="financing_type"/>
|
||||
<field name="financing_type" colspan="3"/>
|
||||
<label name="currency"/>
|
||||
<field name="currency"/>
|
||||
<label name="amount"/>
|
||||
<field name="amount"/>
|
||||
<label name="date_from"/>
|
||||
<field name="date_from"/>
|
||||
<label name="date_to"/>
|
||||
<field name="date_to"/>
|
||||
<label name="tenor"/>
|
||||
<field name="tenor"/>
|
||||
<label name="sequence"/>
|
||||
@@ -17,10 +23,13 @@
|
||||
<field name="parent" colspan="3"/>
|
||||
<label name="facility"/>
|
||||
<field name="facility" colspan="3"/>
|
||||
<label name="notes"/>
|
||||
<field name="notes" colspan="3"/>
|
||||
</group>
|
||||
<notebook colspan="4">
|
||||
<page string="Sub-Limits" id="children">
|
||||
<field name="children" colspan="4"/>
|
||||
<field name="children" colspan="4"
|
||||
view_ids="trade_finance.facility_limit_view_tree,trade_finance.facility_limit_view_form"/>
|
||||
</page>
|
||||
<page string="Haircuts" id="haircuts">
|
||||
<field name="haircuts" colspan="4"/>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<tree expand="1">
|
||||
<field name="name"/>
|
||||
<field name="parent"/>
|
||||
<tree expand="1" keyword_open="1">
|
||||
<field name="name" expand="2"/>
|
||||
<field name="parent" tree_invisible="1"/>
|
||||
<field name="children" tree_invisible="1"/>
|
||||
<field name="financing_type"/>
|
||||
<field name="currency"/>
|
||||
<field name="amount"/>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="tenor"/>
|
||||
</tree>
|
||||
|
||||
Reference in New Issue
Block a user