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 @@
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/charter_condition_tree.xml b/modules/purchase_trade/view/charter_condition_tree.xml
index 2769297..3f27b78 100644
--- a/modules/purchase_trade/view/charter_condition_tree.xml
+++ b/modules/purchase_trade/view/charter_condition_tree.xml
@@ -4,6 +4,10 @@
+
+
+
+