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 @@ + +
+