Demurrage

This commit is contained in:
2026-06-03 14:30:16 +02:00
parent 5763ca64a5
commit adcce718fd
6 changed files with 121 additions and 50 deletions

View File

@@ -35,7 +35,7 @@ canonique seulement quand elle est reprise dans une page thematique.
</li>
<li style="margin:0.38rem 0;">Les shipments et SOF exposent les conditions Owner, Supplier et Customer retrouvees depuis le charter party et les lots/lignes physiques.
</li>
<li style="margin:0.38rem 0;">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.
<li style="margin:0.38rem 0;">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.
</li>
<li style="margin:0.38rem 0;">Un solde de laytime negatif cree une compensation <code>demurrage</code>; un solde positif peut creer une compensation <code>despatch</code> si un rate despatch est defini.
</li>

View File

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

View File

@@ -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):

View File

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

View File

@@ -14,16 +14,20 @@
<field name="laytime_allowed"/>
<label name="laytime_unit"/>
<field name="laytime_unit"/>
<label name="turn_time"/>
<field name="turn_time"/>
<label name="turn_time_unit"/>
<field name="turn_time_unit"/>
<label name="reversible"/>
<field name="reversible"/>
<label name="all_time_saved"/>
<field name="all_time_saved"/>
</page>
<page string="Laytime" col="4" id="laytime">
<label name="laytime_start_event"/>
<field name="laytime_start_event"/>
<label name="laytime_end_event"/>
<field name="laytime_end_event"/>
<label name="laytime_start_offset"/>
<field name="laytime_start_offset"/>
<label name="laytime_start_offset_unit"/>
<field name="laytime_start_offset_unit"/>
<label name="laytime_clause"/>
<field name="laytime_clause" colspan="3"/>
<label name="laytime_start"/>

View File

@@ -4,6 +4,10 @@
<field name="party"/>
<field name="party_role"/>
<field name="responsibility"/>
<field name="laytime_start_event"/>
<field name="laytime_start_offset"/>
<field name="laytime_start_offset_unit"/>
<field name="laytime_end_event"/>
<field name="laytime_allowed"/>
<field name="laytime_unit"/>
</tree>