diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index b96f4e0..b7bbb09 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -125,6 +125,10 @@ def register():
stock.CharterConditionRate,
stock.CharterCondition,
stock.CharterParty,
+ purchase.ContractClause,
+ purchase.ContractTemplate,
+ purchase.ContractTemplateLine,
+ purchase.ContractClauseSelection,
purchase.Purchase,
purchase.Line,
purchase.Estimated,
@@ -267,6 +271,7 @@ def register():
purchase.PurchaseCrop,
module='lot', type_='model')
Pool.register(
+ sale.ContractClauseSelection,
sale.CharterCondition,
sale.Sale,
sale.SaleLine,
diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py
index 18ade70..97dd076 100755
--- a/modules/purchase_trade/purchase.py
+++ b/modules/purchase_trade/purchase.py
@@ -17,10 +17,11 @@ from sql.functions import CurrentTimestamp, DateTrunc, Abs
from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction
from itertools import chain, groupby
from operator import itemgetter
-import datetime
-import logging
-import json
-import jwt
+import datetime
+import logging
+import json
+import re
+import jwt
from collections import defaultdict
from trytond.exceptions import UserWarning, UserError
from trytond.modules.purchase_trade.numbers_to_words import quantity_to_words, amount_to_currency_words, format_date_en
@@ -262,6 +263,14 @@ class Purchase(metaclass=PoolMeta):
btb = fields.Many2One('back.to.back',"Back to back")
charter_conditions = fields.One2Many(
'charter.condition', 'purchase', "Charter Conditions")
+ contract_template = fields.Many2One(
+ 'contract.template', "Contract Template",
+ domain=[
+ ('direction', 'in', ['both', 'purchase']),
+ ('active', '=', True),
+ ])
+ contract_clauses = fields.One2Many(
+ 'contract.clause.selection', 'purchase', "Contract Clauses")
bank_accounts = fields.Function(
fields.Many2Many('bank.account', None, None, "Bank Accounts"),
'on_change_with_bank_accounts')
@@ -731,10 +740,36 @@ class Purchase(metaclass=PoolMeta):
if line.price_type == 'efp':
if line.derivatives:
for d in line.derivatives:
- line.unit_price = round(Decimal(d.price_index.get_price(Date.today(),line.unit,line.currency,True)),4)
- logger.info("EFP_PRICE:%s",line.unit_price)
- Line.save([line])
-
+ line.unit_price = round(Decimal(d.price_index.get_price(Date.today(),line.unit,line.currency,True)),4)
+ logger.info("EFP_PRICE:%s",line.unit_price)
+ Line.save([line])
+
+ def _contract_clause_values_from_template(self, direction):
+ values = []
+ template = getattr(self, 'contract_template', None)
+ if not template:
+ return values
+ for template_line in sorted(
+ template.lines or [], key=lambda line: line.sequence or 0):
+ clause = template_line.clause
+ if not clause or not clause.matches_contract(self, direction):
+ continue
+ values.append(ContractClauseSelection(
+ sequence=template_line.sequence,
+ section=template_line.section or clause.category,
+ clause=clause,
+ selected=not template_line.optional,
+ optional=template_line.optional,
+ ))
+ return values
+
+ @fields.depends('contract_template', 'contract_clauses', 'incoterm',
+ 'payment_term', 'lines')
+ def on_change_contract_template(self):
+ if self.contract_template:
+ self.contract_clauses = self._contract_clause_values_from_template(
+ 'purchase')
+
class PriceComposition(ModelSQL,ModelView):
"Price Composition"
__name__ = 'price.composition'
@@ -786,8 +821,257 @@ class PremiumComposition(ModelSQL, ModelView):
return round(Currency.compute(
currency, amount, contract_currency), 6)
return round(Currency.compute(currency, amount, contract_currency), 6)
-
-class AssayImporter:
+
+
+CONTRACT_CLAUSE_CATEGORIES = [
+ (None, ''),
+ ('payment', 'Payment'),
+ ('delivery', 'Delivery'),
+ ('transport', 'Transport'),
+ ('documents', 'Documents'),
+ ('quality', 'Quality'),
+ ('quantity', 'Quantity'),
+ ('price', 'Price'),
+ ('insurance', 'Insurance'),
+ ('title_transfer', 'Title Transfer'),
+ ('charter', 'Charter / Demurrage'),
+ ('tax', 'Tax'),
+ ('sanctions', 'Sanctions'),
+ ('force_majeure', 'Force Majeure'),
+ ('law', 'Law / Arbitration'),
+ ('other', 'Other'),
+]
+
+
+class ContractClause(ModelSQL, ModelView):
+ "Contract Clause"
+ __name__ = 'contract.clause'
+
+ name = fields.Char("Name", required=True)
+ category = fields.Selection(CONTRACT_CLAUSE_CATEGORIES, "Category")
+ direction = fields.Selection([
+ ('both', 'Purchase & Sale'),
+ ('purchase', 'Purchase'),
+ ('sale', 'Sale'),
+ ], "Direction", required=True)
+ active = fields.Boolean("Active")
+ version = fields.Char("Version")
+ language = fields.Char("Language")
+ source = fields.Char("Source")
+ text = fields.Text("Text", required=True)
+ incoterm = fields.Many2One('incoterm.incoterm', "Incoterm")
+ product = fields.Many2One('product.product', "Product")
+ payment_term = fields.Many2One(
+ 'account.invoice.payment_term', "Payment Term")
+ transport_type = fields.Selection([
+ (None, ''),
+ ('vessel', 'Vessel'),
+ ('truck', 'Truck'),
+ ('other', 'Other'),
+ ], "Transport Type")
+ cargo_mode = fields.Selection([
+ (None, ''),
+ ('bulk', 'Bulk'),
+ ('container', 'Container'),
+ ], "Cargo Mode")
+ variables = fields.Function(
+ fields.Char("Variables"), 'get_variables')
+
+ @staticmethod
+ def default_active():
+ return True
+
+ @staticmethod
+ def default_direction():
+ return 'both'
+
+ def get_variables(self, name=None):
+ variables = sorted(set(re.findall(
+ r'\[([A-Za-z0-9_]+)\]', self.text or '')))
+ return ', '.join(variables)
+
+ def matches_contract(self, contract, direction):
+ if self.direction not in ('both', direction):
+ return False
+ if (self.incoterm
+ and getattr(getattr(contract, 'incoterm', None), 'id', None)
+ != self.incoterm.id):
+ return False
+ if (self.payment_term
+ and getattr(getattr(contract, 'payment_term', None), 'id', None)
+ != self.payment_term.id):
+ return False
+ if self.product:
+ products = {
+ getattr(getattr(line, 'product', None), 'id', None)
+ for line in getattr(contract, 'lines', []) or []
+ if getattr(line, 'type', 'line') == 'line'
+ }
+ if self.product.id not in products:
+ return False
+ if (self.transport_type
+ and getattr(contract, 'transport_type', None)
+ != self.transport_type):
+ return False
+ if self.cargo_mode and getattr(contract, 'cargo_mode', None) != self.cargo_mode:
+ return False
+ return True
+
+
+class ContractTemplate(ModelSQL, ModelView):
+ "Contract Template"
+ __name__ = 'contract.template'
+
+ name = fields.Char("Name", required=True)
+ direction = fields.Selection([
+ ('both', 'Purchase & Sale'),
+ ('purchase', 'Purchase'),
+ ('sale', 'Sale'),
+ ], "Direction", required=True)
+ active = fields.Boolean("Active")
+ incoterm = fields.Many2One('incoterm.incoterm', "Incoterm")
+ product = fields.Many2One('product.product', "Product")
+ payment_term = fields.Many2One(
+ 'account.invoice.payment_term', "Payment Term")
+ transport_type = fields.Selection([
+ (None, ''),
+ ('vessel', 'Vessel'),
+ ('truck', 'Truck'),
+ ('other', 'Other'),
+ ], "Transport Type")
+ cargo_mode = fields.Selection([
+ (None, ''),
+ ('bulk', 'Bulk'),
+ ('container', 'Container'),
+ ], "Cargo Mode")
+ lines = fields.One2Many(
+ 'contract.template.line', 'template', "Clauses")
+ notes = fields.Text("Notes")
+
+ @staticmethod
+ def default_active():
+ return True
+
+ @staticmethod
+ def default_direction():
+ return 'both'
+
+ def matches_contract(self, contract, direction):
+ if self.direction not in ('both', direction):
+ return False
+ if (self.incoterm
+ and getattr(getattr(contract, 'incoterm', None), 'id', None)
+ != self.incoterm.id):
+ return False
+ if (self.payment_term
+ and getattr(getattr(contract, 'payment_term', None), 'id', None)
+ != self.payment_term.id):
+ return False
+ if self.product:
+ products = {
+ getattr(getattr(line, 'product', None), 'id', None)
+ for line in getattr(contract, 'lines', []) or []
+ if getattr(line, 'type', 'line') == 'line'
+ }
+ if self.product.id not in products:
+ return False
+ if (self.transport_type
+ and getattr(contract, 'transport_type', None)
+ != self.transport_type):
+ return False
+ if self.cargo_mode and getattr(contract, 'cargo_mode', None) != self.cargo_mode:
+ return False
+ return True
+
+
+class ContractTemplateLine(ModelSQL, ModelView):
+ "Contract Template Line"
+ __name__ = 'contract.template.line'
+
+ template = fields.Many2One(
+ 'contract.template', "Template", required=True, ondelete='CASCADE')
+ sequence = fields.Integer("Sequence")
+ section = fields.Selection(CONTRACT_CLAUSE_CATEGORIES, "Section")
+ clause = fields.Many2One('contract.clause', "Clause", required=True)
+ optional = fields.Boolean("Optional")
+
+ @staticmethod
+ def default_sequence():
+ return 10
+
+
+class ContractClauseSelection(ModelSQL, ModelView):
+ "Contract Clause Selection"
+ __name__ = 'contract.clause.selection'
+
+ purchase = fields.Many2One(
+ 'purchase.purchase', "Purchase", ondelete='CASCADE')
+ sequence = fields.Integer("Sequence")
+ section = fields.Selection(CONTRACT_CLAUSE_CATEGORIES, "Section")
+ clause = fields.Many2One('contract.clause', "Clause", required=True)
+ selected = fields.Boolean("Selected")
+ optional = fields.Boolean("Optional")
+ text_override = fields.Text("Override")
+ rendered_text = fields.Function(
+ fields.Text("Rendered Text"), 'get_rendered_text')
+
+ @staticmethod
+ def default_sequence():
+ return 10
+
+ @staticmethod
+ def default_selected():
+ return True
+
+ def _get_contract(self):
+ return getattr(self, 'purchase', None) or getattr(self, 'sale', None)
+
+ def _get_render_context(self):
+ contract = self._get_contract()
+ if not contract:
+ return {}
+ lines = [
+ line for line in getattr(contract, 'lines', []) or []
+ if getattr(line, 'type', 'line') == 'line']
+ products = ', '.join(filter(None, [
+ getattr(getattr(line, 'product', None), 'rec_name', None)
+ for line in lines]))
+ quantities = ', '.join(filter(None, [
+ str(getattr(line, 'quantity', '') or '')
+ for line in lines]))
+ units = ', '.join(filter(None, [
+ getattr(getattr(line, 'unit', None), 'rec_name', None)
+ for line in lines]))
+ return {
+ 'Contract_Number': (
+ getattr(contract, 'reference', None)
+ or getattr(contract, 'number', None) or ''),
+ 'reference': getattr(contract, 'reference', None) or '',
+ 'party': getattr(getattr(contract, 'party', None), 'rec_name', None) or '',
+ 'company': getattr(getattr(contract, 'company', None), 'rec_name', None) or '',
+ 'currency': getattr(getattr(contract, 'currency', None), 'rec_name', None) or '',
+ 'incoterm': getattr(getattr(contract, 'incoterm', None), 'code', None) or '',
+ 'incoterm_location': getattr(getattr(contract, 'incoterm_location', None), 'rec_name', None) or '',
+ 'payment_term': getattr(getattr(contract, 'payment_term', None), 'rec_name', None) or '',
+ 'product': products,
+ 'quantity': quantities,
+ 'unit': units,
+ 'product_origin': getattr(contract, 'product_origin', None) or '',
+ 'del_from': str(getattr(contract, 'del_from', '') or ''),
+ 'del_to': str(getattr(contract, 'del_to', '') or ''),
+ }
+
+ def get_rendered_text(self, name=None):
+ text = self.text_override or getattr(self.clause, 'text', '') or ''
+ context = self._get_render_context()
+
+ def replace(match):
+ key = match.group(1)
+ return str(context.get(key, match.group(0)))
+
+ return re.sub(r'\[([A-Za-z0-9_]+)\]', replace, text)
+
+class AssayImporter:
def __init__(self):
pool = Pool()
diff --git a/modules/purchase_trade/purchase.xml b/modules/purchase_trade/purchase.xml
index 180a4b4..91a702c 100755
--- a/modules/purchase_trade/purchase.xml
+++ b/modules/purchase_trade/purchase.xml
@@ -172,6 +172,105 @@ this repository contains the full copyright notices and license terms. -->
premium_composition_form
+
+ contract.clause
+ tree
+ contract_clause_tree
+
+
+ contract.clause
+ form
+ contract_clause_form
+
+
+ contract.template
+ tree
+ contract_template_tree
+
+
+ contract.template
+ form
+ contract_template_form
+
+
+ contract.template.line
+ tree
+ contract_template_line_tree
+
+
+ contract.template.line
+ form
+ contract_template_line_form
+
+
+ contract.clause.selection
+ tree
+ contract_clause_selection_tree
+
+
+ contract.clause.selection
+ form
+ contract_clause_selection_form
+
+
+
+ Contract Clauses
+ contract.clause
+
+
+
+
+
+
+
+
+
+
+
+
+ Contract Templates
+ contract.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+ contract.clause
+
+
+
+
+
+
+ contract.template
+
+
+
+
+
+
+ contract.template.line
+
+
+
+
+
+
+ contract.clause.selection
+
+
+
+
+
+
quality.analysis
tree
@@ -251,10 +350,22 @@ this repository contains the full copyright notices and license terms. -->
sequence="120"
id="menu_position_bi"/>
-
-
+ sequence="100"
+ action="act_pur_inv_report_form"
+ id="menu_pur_inv_report_form"/>
+
+
+
diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py
index bb92118..fd82737 100755
--- a/modules/purchase_trade/sale.py
+++ b/modules/purchase_trade/sale.py
@@ -227,6 +227,14 @@ class Sale(metaclass=PoolMeta):
btb = fields.Many2One('back.to.back',"Back to back")
charter_conditions = fields.One2Many(
'charter.condition', 'sale', "Charter Conditions")
+ contract_template = fields.Many2One(
+ 'contract.template', "Contract Template",
+ domain=[
+ ('direction', 'in', ['both', 'sale']),
+ ('active', '=', True),
+ ])
+ contract_clauses = fields.One2Many(
+ 'contract.clause.selection', 'sale', "Contract Clauses")
bank_accounts = fields.Function(
fields.Many2Many('bank.account', None, None, "Bank Accounts"),
'on_change_with_bank_accounts')
@@ -1247,12 +1255,45 @@ class Sale(metaclass=PoolMeta):
line.unit_price = d.price_index.get_price(Date.today(),line.unit,line.currency,True)
Line.save([line])
+ def _contract_clause_values_from_template(self, direction):
+ Selection = Pool().get('contract.clause.selection')
+ values = []
+ template = getattr(self, 'contract_template', None)
+ if not template:
+ return values
+ for template_line in sorted(
+ template.lines or [], key=lambda line: line.sequence or 0):
+ clause = template_line.clause
+ if not clause or not clause.matches_contract(self, direction):
+ continue
+ values.append(Selection(
+ sequence=template_line.sequence,
+ section=template_line.section or clause.category,
+ clause=clause,
+ selected=not template_line.optional,
+ optional=template_line.optional,
+ ))
+ return values
+
+ @fields.depends('contract_template', 'contract_clauses', 'incoterm',
+ 'payment_term', 'lines')
+ def on_change_contract_template(self):
+ if self.contract_template:
+ self.contract_clauses = self._contract_clause_values_from_template(
+ 'sale')
+
class PriceComposition(metaclass=PoolMeta):
__name__ = 'price.composition'
sale_line = fields.Many2One('sale.line',"Sale line")
+class ContractClauseSelection(metaclass=PoolMeta):
+ __name__ = 'contract.clause.selection'
+
+ sale = fields.Many2One('sale.sale', "Sale", ondelete='CASCADE')
+
+
class CharterCondition(metaclass=PoolMeta):
__name__ = 'charter.condition'
diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py
index a34b63a..1fb60e0 100755
--- a/modules/purchase_trade/stock.py
+++ b/modules/purchase_trade/stock.py
@@ -1946,8 +1946,13 @@ class StatementOfFacts(ModelSQL, ModelView):
timebar_day = fields.Integer("TimeBar")
timebar_warn = fields.Integer("Warning TimeBar")
charter_party = fields.Many2One('stock.charter.party', "Charter Party")
+ applied_conditions = fields.Function(
+ fields.One2Many('charter.condition', '', "Available Conditions"),
+ 'get_applied_conditions')
applied_condition = fields.Many2One(
- 'charter.condition', "Applied Condition")
+ 'charter.condition', "Applied Condition",
+ domain=[('id', 'in', Eval('applied_conditions', []))],
+ depends=['applied_conditions'])
owner_charter_conditions = fields.Function(
fields.One2Many('charter.condition', '', "Owner Conditions"),
'get_owner_charter_conditions')
@@ -2021,6 +2026,12 @@ class StatementOfFacts(ModelSQL, ModelView):
return self.shipment.get_sale_charter_conditions(name)
return []
+ def get_applied_conditions(self, name=None):
+ return ShipmentIn._unique_condition_ids(
+ self.get_owner_charter_conditions(name)
+ + self.get_purchase_charter_conditions(name)
+ + self.get_sale_charter_conditions(name))
+
@staticmethod
def _rate_category(rate):
rate_type = getattr(rate, 'rate_type', None)
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index a8a31c3..3568175 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -174,6 +174,40 @@ class PurchaseTradeTestCase(ModuleTestCase):
sof._applied_compensation_amount(), Decimal('-12000.00'))
self.assertEqual(sof.compensation_type, 'demurrage')
+ def test_contract_clause_variables_and_rendering_use_contract_context(self):
+ 'contract clauses expose placeholders and render from purchase context'
+ clause = purchase_module.ContractClause()
+ clause.name = 'Payment'
+ clause.direction = 'both'
+ clause.text = (
+ 'Buyer pays [party] under [incoterm] for [product] '
+ 'contract [Contract_Number].')
+
+ self.assertEqual(
+ clause.get_variables('variables'),
+ 'Contract_Number, incoterm, party, product')
+
+ selection = purchase_module.ContractClauseSelection()
+ selection.clause = clause
+ selection.purchase = Mock(
+ reference='PUR-001',
+ party=Mock(rec_name='Supplier Ltd'),
+ company=Mock(rec_name='Company SA'),
+ currency=Mock(rec_name='USD'),
+ incoterm=Mock(code='FOB'),
+ incoterm_location=None,
+ payment_term=None,
+ product_origin='',
+ lines=[
+ Mock(type='line', product=Mock(rec_name='Steel'), quantity=10,
+ unit=Mock(rec_name='MT')),
+ ],
+ )
+
+ self.assertEqual(
+ selection.get_rendered_text('rendered_text'),
+ 'Buyer pays Supplier Ltd under FOB for Steel contract PUR-001.')
+
@with_transaction()
def test_add_physical_lot_defaults_hidden_premium_and_chunk_key(self):
'add physical lot works when hidden tree fields are not loaded'
diff --git a/modules/purchase_trade/view/contract_clause_form.xml b/modules/purchase_trade/view/contract_clause_form.xml
new file mode 100644
index 0000000..7dc0b6a
--- /dev/null
+++ b/modules/purchase_trade/view/contract_clause_form.xml
@@ -0,0 +1,34 @@
+
+
diff --git a/modules/purchase_trade/view/contract_clause_selection_form.xml b/modules/purchase_trade/view/contract_clause_selection_form.xml
new file mode 100644
index 0000000..9d36e08
--- /dev/null
+++ b/modules/purchase_trade/view/contract_clause_selection_form.xml
@@ -0,0 +1,17 @@
+
+
diff --git a/modules/purchase_trade/view/contract_clause_selection_tree.xml b/modules/purchase_trade/view/contract_clause_selection_tree.xml
new file mode 100644
index 0000000..92eefe5
--- /dev/null
+++ b/modules/purchase_trade/view/contract_clause_selection_tree.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/contract_clause_tree.xml b/modules/purchase_trade/view/contract_clause_tree.xml
new file mode 100644
index 0000000..d2ebc4f
--- /dev/null
+++ b/modules/purchase_trade/view/contract_clause_tree.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/contract_template_form.xml b/modules/purchase_trade/view/contract_template_form.xml
new file mode 100644
index 0000000..cd7e5d9
--- /dev/null
+++ b/modules/purchase_trade/view/contract_template_form.xml
@@ -0,0 +1,28 @@
+
+
diff --git a/modules/purchase_trade/view/contract_template_line_form.xml b/modules/purchase_trade/view/contract_template_line_form.xml
new file mode 100644
index 0000000..46e0b9e
--- /dev/null
+++ b/modules/purchase_trade/view/contract_template_line_form.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/modules/purchase_trade/view/contract_template_line_tree.xml b/modules/purchase_trade/view/contract_template_line_tree.xml
new file mode 100644
index 0000000..deeac76
--- /dev/null
+++ b/modules/purchase_trade/view/contract_template_line_tree.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/contract_template_tree.xml b/modules/purchase_trade/view/contract_template_tree.xml
new file mode 100644
index 0000000..bf8d73f
--- /dev/null
+++ b/modules/purchase_trade/view/contract_template_tree.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/purchase_form.xml b/modules/purchase_trade/view/purchase_form.xml
index 8397b73..36f73f5 100755
--- a/modules/purchase_trade/view/purchase_form.xml
+++ b/modules/purchase_trade/view/purchase_form.xml
@@ -58,6 +58,12 @@ this repository contains the full copyright notices and license terms. -->
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/sale_form.xml b/modules/purchase_trade/view/sale_form.xml
index 5746ea2..03cf62a 100755
--- a/modules/purchase_trade/view/sale_form.xml
+++ b/modules/purchase_trade/view/sale_form.xml
@@ -58,6 +58,12 @@ this repository contains the full copyright notices and license terms. -->
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/sof_form.xml b/modules/purchase_trade/view/sof_form.xml
index 8696a5c..14e814a 100755
--- a/modules/purchase_trade/view/sof_form.xml
+++ b/modules/purchase_trade/view/sof_form.xml
@@ -3,6 +3,7 @@
+