diff --git a/modules/purchase_trade/docs/business/sessions.md b/modules/purchase_trade/docs/business/sessions.md index e6f7aab..484e1cf 100644 --- a/modules/purchase_trade/docs/business/sessions.md +++ b/modules/purchase_trade/docs/business/sessions.md @@ -35,7 +35,7 @@ canonique seulement quand elle est reprise dans une page thematique.
  • Les shipments et SOF exposent les conditions Owner, Supplier et Customer retrouvees depuis le charter party et les lots/lignes physiques.
  • -
  • Le calcul SOF privilegie la condition appliquee: laytime allowed, turn time, pumping rate, rate demurrage et rate despatch. A defaut, il garde les anciens champs legacy demurrage/pumping. +
  • Le calcul SOF privilegie la condition appliquee: laytime allowed, evenement de debut de laytime, offset de debut, evenement de fin, pumping rate, rate demurrage et rate despatch. Les clauses texte laytime/NOR/demurrage/despatch restent contractuelles et ne doivent pas servir de source de calcul.
  • Un solde de laytime negatif cree une compensation demurrage; un solde positif peut creer une compensation despatch si un rate despatch est defini.
  • diff --git a/modules/purchase_trade/docs_source/business/sessions.md b/modules/purchase_trade/docs_source/business/sessions.md index 5159e8d..1072eef 100644 --- a/modules/purchase_trade/docs_source/business/sessions.md +++ b/modules/purchase_trade/docs_source/business/sessions.md @@ -32,8 +32,10 @@ canonique seulement quand elle est reprise dans une page thematique. - Les shipments et SOF exposent les conditions Owner, Supplier et Customer retrouvees depuis le charter party et les lots/lignes physiques. - Le calcul SOF privilegie la condition appliquee: laytime allowed, - turn time, pumping rate, rate demurrage et rate despatch. A defaut, il - garde les anciens champs legacy demurrage/pumping. + evenement de debut de laytime, offset de debut, evenement de fin, + pumping rate, rate demurrage et rate despatch. Les clauses texte + laytime/NOR/demurrage/despatch restent contractuelles et ne doivent pas + servir de source de calcul. - Un solde de laytime negatif cree une compensation `demurrage`; un solde positif peut creer une compensation `despatch` si un rate despatch est defini. diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index c875688..45fa386 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -526,6 +526,33 @@ 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_offset = fields.Numeric( + "Laytime Start Offset", digits=(16, 4)) + laytime_start_offset_unit = fields.Selection([ + (None, ''), + ('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") turn_time = fields.Numeric("Turn Time", digits=(16, 4)) turn_time_unit = fields.Selection([ (None, ''), @@ -549,6 +576,18 @@ class CharterCondition(ModelSQL, ModelView): def default_all_time_saved(): return False + @staticmethod + def default_laytime_start_event(): + return 'notice_of_readiness' + + @staticmethod + def default_laytime_start_offset_unit(): + return 'hours' + + @staticmethod + def default_laytime_end_event(): + return 'hoses_disconnected' + @staticmethod def default_party_role(): role_context = Transaction().context.get('charter_condition_role') @@ -2220,15 +2259,17 @@ class StatementOfFacts(ModelSQL, ModelView): def _applied_turn_delta(self): condition = getattr(self, 'applied_condition', None) - if condition and condition.turn_time is not None: - hours = Decimal(str(condition.turn_time)) - if condition.turn_time_unit == 'days': - hours *= Decimal(24) - return datetime.timedelta(hours=float(hours)) - - text_delta = self._laytime_start_text_delta(condition) - if text_delta is not None: - return text_delta + if condition: + value = getattr(condition, 'laytime_start_offset', None) + unit = getattr(condition, 'laytime_start_offset_unit', None) + if value is None: + 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)) if self.laytime_clause_hour: t = self.laytime_clause_hour @@ -2239,29 +2280,23 @@ class StatementOfFacts(ModelSQL, ModelView): return datetime.timedelta( hours=t.hour, minutes=t.minute, seconds=t.second) - @staticmethod - def _laytime_start_text_delta(condition): - text = (getattr(condition, 'laytime_start', None) or '').lower() - match = re.search( - r'([+-]?\s*\d+(?:[.,]\d+)?)\s*(hours?|hrs?|h|days?|d)', - text) - if not match: - return - quantity = Decimal(match.group(1).replace(' ', '').replace(',', '.')) - unit = match.group(2) - if unit.startswith('d'): - quantity *= Decimal(24) - return datetime.timedelta(hours=float(quantity)) + def _condition_event_datetime(self, event): + return { + 'notice_of_readiness': self.notice_of_readiness_time, + 'arrival': self.arrival_time, + 'hoses_connected': self.hoses_connected, + 'start_pumping': self.start_pumping, + 'end_pumping': self.end_pumping, + 'hoses_disconnected': self.hoses_disconnected, + 'sailing': self.sailing_time, + }.get(event) def _laytime_start_base(self): condition = getattr(self, 'applied_condition', None) - text = (getattr(condition, 'laytime_start', None) or '').lower() - if 'arrival' in text: - return self.arrival_time - if 'hose' in text and 'connect' in text: - return self.hoses_connected - if 'pump' in text or 'loading' in text: - return self.start_pumping + event = getattr(condition, 'laytime_start_event', None) + base = self._condition_event_datetime(event) + if base: + return base return self.notice_of_readiness_time def _applied_laytime_start(self): @@ -2272,13 +2307,10 @@ class StatementOfFacts(ModelSQL, ModelView): def _applied_laytime_end(self): condition = getattr(self, 'applied_condition', None) - text = (getattr(condition, 'laytime_end', None) or '').lower() - if 'pump' in text or 'loading' in text: - return self.end_pumping - if 'sailing' in text or 'sailed' in text: - return self.sailing_time - if 'hose' in text and ('disconnect' in text or 'off' in text): - return self.hoses_disconnected + event = getattr(condition, 'laytime_end_event', None) + end = self._condition_event_datetime(event) + if end: + return end return self.hoses_disconnected or self.end_pumping or self.sailing_time def _applied_compensation_amount(self): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 4df537b..288a8e1 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -197,6 +197,8 @@ class PurchaseTradeTestCase(ModuleTestCase): sof = Sof() sof.applied_condition = Mock( laytime_allowed=None, + laytime_start_offset=None, + laytime_start_offset_unit=None, turn_time=Decimal('6'), turn_time_unit='hours', rates=[ @@ -215,13 +217,15 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(sof.compensation_type, 'demurrage') @with_transaction() - def test_sof_laytime_start_reads_text_clause_turn_time(self): - 'sof laytime start reads NOR plus hours from the condition text' + def test_sof_laytime_start_reads_structured_condition_rule(self): + 'sof laytime start reads the structured event and offset' Sof = Pool().get('sof.statement') sof = Sof() sof.applied_condition = Mock( - turn_time=None, - laytime_start='NOR + 6 hours') + laytime_start_event='notice_of_readiness', + laytime_start_offset=Decimal('6'), + laytime_start_offset_unit='hours', + turn_time=None) sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0) self.assertEqual( @@ -229,11 +233,11 @@ class PurchaseTradeTestCase(ModuleTestCase): datetime.datetime(2026, 6, 1, 8, 0)) @with_transaction() - def test_sof_laytime_end_reads_hoses_disconnected_clause(self): - 'sof laytime end can use hoses disconnected from the condition text' + def test_sof_laytime_end_reads_structured_condition_rule(self): + 'sof laytime end reads the structured end event' Sof = Pool().get('sof.statement') sof = Sof() - sof.applied_condition = Mock(laytime_end='Hoses disconnected') + sof.applied_condition = Mock(laytime_end_event='hoses_disconnected') sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0) sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0) @@ -241,6 +245,31 @@ class PurchaseTradeTestCase(ModuleTestCase): sof._applied_laytime_end(), datetime.datetime(2026, 6, 1, 13, 0)) + @with_transaction() + def test_sof_laytime_ignores_free_text_clauses(self): + 'sof laytime calculation ignores contractual free text fields' + Sof = Pool().get('sof.statement') + sof = Sof() + sof.applied_condition = Mock( + laytime_start_event='arrival', + laytime_start_offset=Decimal('1'), + laytime_start_offset_unit='hours', + laytime_end_event='end_pumping', + laytime_start='NOR + 6 hours', + laytime_end='Hoses disconnected', + turn_time=None) + sof.arrival_time = datetime.datetime(2026, 6, 1, 3, 0) + sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0) + sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0) + sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0) + + self.assertEqual( + sof._applied_laytime_start(), + datetime.datetime(2026, 6, 1, 4, 0)) + self.assertEqual( + sof._applied_laytime_end(), + datetime.datetime(2026, 6, 1, 12, 0)) + @with_transaction() def test_sof_header_date_time_values_sync_to_datetime(self): 'sof header date and time fields build the technical datetime value' diff --git a/modules/purchase_trade/view/charter_condition_form.xml b/modules/purchase_trade/view/charter_condition_form.xml index b16f117..77171da 100644 --- a/modules/purchase_trade/view/charter_condition_form.xml +++ b/modules/purchase_trade/view/charter_condition_form.xml @@ -14,16 +14,20 @@