diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index ffd2511..f52d2fe 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -141,10 +141,11 @@ def register(): party.PartyExecutionPlace, payment_term.PaymentTerm, payment_term.PaymentTermLine, - stock.CharterRateType, - stock.CharterConditionRate, - stock.CharterCondition, - stock.CharterParty, + stock.CharterRateType, + stock.CharterConditionRate, + stock.CharterConditionLaytimeStartRule, + stock.CharterCondition, + stock.CharterParty, purchase.ContractClause, purchase.ContractTemplate, purchase.ContractTemplateLine, diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index 0ce1be1..e8abb5c 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -29,9 +29,21 @@ from trytond.exceptions import UserError from trytond.modules.stock.shipment import SupplierShipping as BaseSupplierShipping -logger = logging.getLogger(__name__) - -class Location(metaclass=PoolMeta): +logger = logging.getLogger(__name__) + +CHARTER_LAYTIME_EVENT_SELECTION = [ + (None, ''), + ('notice_of_readiness', 'Notice of Readiness'), + ('arrival', 'Arrival'), + ('all_fast', 'All Fast'), + ('hoses_connected', 'Hoses Connected'), + ('start_pumping', 'Start Pumping'), + ('end_pumping', 'End Pumping'), + ('hoses_disconnected', 'Hoses Disconnected'), + ('sailing', 'Sailing'), +] + +class Location(metaclass=PoolMeta): __name__ = 'stock.location' def get_places(self): @@ -519,6 +531,31 @@ class CharterConditionRate(ModelSQL, ModelView): self.basis = self.rate_type.default_basis +class CharterConditionLaytimeStartRule(ModelSQL, ModelView): + "Charter Condition Laytime Start Rule" + __name__ = 'charter.condition.laytime.start.rule' + + condition = fields.Many2One( + 'charter.condition', "Condition", required=True, ondelete='CASCADE') + sequence = fields.Integer("Sequence") + event = fields.Selection( + CHARTER_LAYTIME_EVENT_SELECTION, "Event", required=True) + offset = fields.Numeric("Offset", digits=(16, 4)) + offset_unit = fields.Selection([ + (None, ''), + ('hours', 'Hours'), + ('days', 'Days'), + ], "Offset Unit") + + @staticmethod + def default_sequence(): + return 10 + + @staticmethod + def default_offset_unit(): + return 'hours' + + class CharterCondition(ModelSQL, ModelView): "Charter Condition" __name__ = 'charter.condition' @@ -568,16 +605,13 @@ class CharterCondition(ModelSQL, ModelView): laytime_start = fields.Text("Laytime Start") laytime_end = fields.Text("Laytime End") nor_clause = fields.Text("NOR Clause") - laytime_start_event = fields.Selection([ - (None, ''), - ('notice_of_readiness', 'Notice of Readiness'), - ('arrival', 'Arrival'), - ('hoses_connected', 'Hoses Connected'), - ('start_pumping', 'Start Pumping'), - ('end_pumping', 'End Pumping'), - ('hoses_disconnected', 'Hoses Disconnected'), - ('sailing', 'Sailing'), - ], "Laytime Start Event") + laytime_start_rule = fields.Selection([ + ('simple', 'Single Event + Offset'), + ('earliest_of', 'Earliest Of'), + ('latest_of', 'Latest Of'), + ], "Laytime Start Rule") + laytime_start_event = fields.Selection( + CHARTER_LAYTIME_EVENT_SELECTION, "Laytime Start Event") laytime_start_offset = fields.Numeric( "Laytime Start Offset", digits=(16, 4)) laytime_start_offset_unit = fields.Selection([ @@ -585,16 +619,11 @@ class CharterCondition(ModelSQL, ModelView): ('hours', 'Hours'), ('days', 'Days'), ], "Laytime Start Offset Unit") - laytime_end_event = fields.Selection([ - (None, ''), - ('notice_of_readiness', 'Notice of Readiness'), - ('arrival', 'Arrival'), - ('hoses_connected', 'Hoses Connected'), - ('start_pumping', 'Start Pumping'), - ('end_pumping', 'End Pumping'), - ('hoses_disconnected', 'Hoses Disconnected'), - ('sailing', 'Sailing'), - ], "Laytime End Event") + laytime_start_rules = fields.One2Many( + 'charter.condition.laytime.start.rule', 'condition', + "Laytime Start Candidates") + laytime_end_event = fields.Selection( + CHARTER_LAYTIME_EVENT_SELECTION, "Laytime End Event") turn_time = fields.Numeric("Turn Time", digits=(16, 4)) turn_time_unit = fields.Selection([ (None, ''), @@ -618,6 +647,10 @@ class CharterCondition(ModelSQL, ModelView): def default_all_time_saved(): return False + @staticmethod + def default_laytime_start_rule(): + return 'simple' + @staticmethod def default_laytime_start_event(): return 'notice_of_readiness' @@ -3109,6 +3142,7 @@ class StatementOfFacts(ModelSQL, ModelView): _event_labels = { 'notice_of_readiness': 'Notice of Readiness', 'arrival': 'Arrival', + 'all_fast': 'All Fast', 'hoses_connected': 'Hoses Connected', 'start_pumping': 'Start Pumping', 'end_pumping': 'End Pumping', @@ -3138,6 +3172,7 @@ class StatementOfFacts(ModelSQL, ModelView): 'arrival_time': ('arrival_date', 'arrival_hour'), 'notice_of_readiness_time': ( 'notice_of_readiness_date', 'notice_of_readiness_hour'), + 'all_fast': ('all_fast_date', 'all_fast_hour'), 'hoses_connected': ('hoses_connected_date', 'hoses_connected_hour'), 'start_pumping': ('start_pumping_date', 'start_pumping_hour'), 'end_pumping': ('end_pumping_date', 'end_pumping_hour'), @@ -3206,6 +3241,9 @@ class StatementOfFacts(ModelSQL, ModelView): arrival_time = fields.DateTime('Arrival Time') arrival_date = fields.Date('Arrival Date') arrival_hour = fields.Time('Arrival Time') + all_fast = fields.DateTime('All Fast') + all_fast_date = fields.Date('All Fast Date') + all_fast_hour = fields.Time('All Fast Time') hoses_connected = fields.DateTime('Hoses Connected') hoses_connected_date = fields.Date('Hoses Connected Date') hoses_connected_hour = fields.Time('Hoses Connected Time') @@ -3439,10 +3477,7 @@ class StatementOfFacts(ModelSQL, ModelView): value = getattr(condition, 'turn_time', None) unit = getattr(condition, 'turn_time_unit', None) if value is not None: - hours = Decimal(str(value)) - if unit == 'days': - hours *= Decimal(24) - return datetime.timedelta(hours=float(hours)) + return self._offset_delta(value, unit) if self.laytime_clause_hour: t = self.laytime_clause_hour @@ -3453,10 +3488,20 @@ class StatementOfFacts(ModelSQL, ModelView): return datetime.timedelta( hours=t.hour, minutes=t.minute, seconds=t.second) + @staticmethod + def _offset_delta(value, unit): + if value is None: + return datetime.timedelta(0) + hours = Decimal(str(value)) + if unit == 'days': + hours *= Decimal(24) + return datetime.timedelta(hours=float(hours)) + def _condition_event_datetime(self, event): return { 'notice_of_readiness': self.notice_of_readiness_time, 'arrival': self.arrival_time, + 'all_fast': self.all_fast, 'hoses_connected': self.hoses_connected, 'start_pumping': self.start_pumping, 'end_pumping': self.end_pumping, @@ -3464,8 +3509,26 @@ class StatementOfFacts(ModelSQL, ModelView): 'sailing': self.sailing_time, }.get(event) + def _laytime_start_rule_candidates(self): + condition = getattr(self, 'applied_condition', None) + rules = getattr(condition, 'laytime_start_rules', None) or [] + candidates = [] + for rule in sorted(rules, key=lambda r: getattr(r, 'sequence', 0) or 0): + base = self._condition_event_datetime(getattr(rule, 'event', None)) + if not base: + continue + candidates.append(base + self._offset_delta( + getattr(rule, 'offset', None), + getattr(rule, 'offset_unit', None))) + return candidates + def _laytime_start_base(self): condition = getattr(self, 'applied_condition', None) + rule = getattr(condition, 'laytime_start_rule', 'simple') + if rule in {'earliest_of', 'latest_of'}: + candidates = self._laytime_start_rule_candidates() + if candidates: + return min(candidates) if rule == 'earliest_of' else max(candidates) event = getattr(condition, 'laytime_start_event', None) base = self._condition_event_datetime(event) if base: @@ -3476,6 +3539,10 @@ class StatementOfFacts(ModelSQL, ModelView): base = self._laytime_start_base() if not base: return + condition = getattr(self, 'applied_condition', None) + if getattr(condition, 'laytime_start_rule', 'simple') in { + 'earliest_of', 'latest_of'}: + return base return base + self._applied_turn_delta() def _applied_laytime_end(self): @@ -3517,6 +3584,9 @@ class StatementOfFacts(ModelSQL, ModelView): def _calculation_offset_text(self): condition = getattr(self, 'applied_condition', None) + if getattr(condition, 'laytime_start_rule', 'simple') in { + 'earliest_of', 'latest_of'}: + return self._calculation_start_candidates_text() value = getattr(condition, 'laytime_start_offset', None) unit = getattr(condition, 'laytime_start_offset_unit', None) if value is None: @@ -3531,6 +3601,33 @@ class StatementOfFacts(ModelSQL, ModelView): return legacy.strftime('%H:%M:%S') return '' + def _calculation_start_rule_text(self): + condition = getattr(self, 'applied_condition', None) + rule = getattr(condition, 'laytime_start_rule', 'simple') + if rule == 'earliest_of': + return 'Earliest Of' + if rule == 'latest_of': + return 'Latest Of' + start_event = getattr(condition, 'laytime_start_event', None) + return self._event_labels.get( + start_event, self._event_labels['notice_of_readiness']) + + def _calculation_start_candidates_text(self): + condition = getattr(self, 'applied_condition', None) + rules = getattr(condition, 'laytime_start_rules', None) or [] + parts = [] + for rule in sorted(rules, key=lambda r: getattr(r, 'sequence', 0) or 0): + label = self._event_labels.get(getattr(rule, 'event', None), '') + offset = getattr(rule, 'offset', None) + if offset is not None: + label = '%s + %s %s' % ( + label, + self._format_calculation_number(offset), + getattr(rule, 'offset_unit', None) or 'hours') + if label: + parts.append(label) + return '; '.join(parts) + def _calculation_rate_text(self, category): rate = self._condition_rate(category) if rate is not None: @@ -3594,8 +3691,7 @@ class StatementOfFacts(ModelSQL, ModelView): if name == 'calculation_despatch_rate': return self._calculation_rate_text('despatch') if name == 'calculation_start_event': - return self._event_labels.get( - start_event, self._event_labels['notice_of_readiness']) + return self._calculation_start_rule_text() if name == 'calculation_start_event_time': return self._format_calculation_datetime( self._laytime_start_base()) diff --git a/modules/purchase_trade/stock.xml b/modules/purchase_trade/stock.xml index 50720cc..2e3ce9e 100755 --- a/modules/purchase_trade/stock.xml +++ b/modules/purchase_trade/stock.xml @@ -76,6 +76,16 @@ this repository contains the full copyright notices and license terms. --> form charter_condition_rate_form + + charter.condition.laytime.start.rule + tree + charter_condition_laytime_start_rule_tree + + + charter.condition.laytime.start.rule + form + charter_condition_laytime_start_rule_form + charter.condition tree @@ -164,6 +174,13 @@ this repository contains the full copyright notices and license terms. --> + + charter.condition.laytime.start.rule + + + + + stock.charter.party @@ -196,6 +213,14 @@ this repository contains the full copyright notices and license terms. --> + + charter.condition.laytime.start.rule + + + + + + 🚢 Find vessel diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index a7ea05e..0d2eda8 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -1502,6 +1502,38 @@ class PurchaseTradeTestCase(ModuleTestCase): sof._applied_laytime_start(), datetime.datetime(2026, 6, 1, 8, 0)) + @with_transaction() + def test_sof_laytime_start_uses_earliest_candidate(self): + 'sof laytime start can use the earliest of multiple candidates' + Sof = Pool().get('sof.statement') + sof = Sof() + sof.applied_condition = SimpleNamespace( + laytime_start_rule='earliest_of', + laytime_start_rules=[ + SimpleNamespace( + sequence=10, + event='notice_of_readiness', + offset=Decimal('6'), + offset_unit='hours'), + SimpleNamespace( + sequence=20, + event='all_fast', + offset=None, + offset_unit='hours'), + ]) + sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0) + sof.all_fast = datetime.datetime(2026, 6, 1, 7, 0) + + self.assertEqual( + sof._applied_laytime_start(), + datetime.datetime(2026, 6, 1, 7, 0)) + self.assertEqual( + sof.get_calculation_info('calculation_start_event'), + 'Earliest Of') + self.assertEqual( + sof.get_calculation_info('calculation_start_offset'), + 'Notice of Readiness + 6 hours; All Fast') + @with_transaction() def test_sof_laytime_end_reads_structured_condition_rule(self): 'sof laytime end reads the structured end event' @@ -1593,11 +1625,16 @@ class PurchaseTradeTestCase(ModuleTestCase): values = Sof._sync_datetime_values({ 'notice_of_readiness_date': datetime.date(2026, 7, 6), 'notice_of_readiness_hour': datetime.time(8, 0), + 'all_fast_date': datetime.date(2026, 7, 6), + 'all_fast_hour': datetime.time(10, 30), }) self.assertEqual( values['notice_of_readiness_time'], datetime.datetime(2026, 7, 6, 8, 0)) + self.assertEqual( + values['all_fast'], + datetime.datetime(2026, 7, 6, 10, 30)) @with_transaction() def test_sof_event_date_time_values_sync_to_datetime(self): diff --git a/modules/purchase_trade/view/charter_condition_form.xml b/modules/purchase_trade/view/charter_condition_form.xml index 77171da..b8f08b6 100644 --- a/modules/purchase_trade/view/charter_condition_form.xml +++ b/modules/purchase_trade/view/charter_condition_form.xml @@ -20,6 +20,9 @@ +