demurrage

This commit is contained in:
2026-06-03 16:29:50 +02:00
parent adcce718fd
commit 4a0461df44
3 changed files with 242 additions and 0 deletions

View File

@@ -1995,6 +1995,34 @@ class StatementOfFacts(ModelSQL, ModelView):
"Statement of Facts for Vessel Discharge"
__name__ = 'sof.statement'
_event_labels = {
'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',
}
_party_role_labels = {
'owner': 'Owner',
'charterer': 'Charterer',
'supplier': 'Supplier',
'customer': 'Customer',
'broker': 'Broker',
'agent': 'Agent',
'terminal': 'Terminal',
'other': 'Other',
}
_responsibility_labels = {
'ours': 'Ours',
'counterparty': 'Counterparty',
'shared': 'Shared',
'pass_through': 'Pass-through',
'owner': 'Owner',
'charterer': 'Charterer',
}
_datetime_parts = {
'arrival_time': ('arrival_date', 'arrival_hour'),
'notice_of_readiness_time': (
@@ -2107,6 +2135,32 @@ class StatementOfFacts(ModelSQL, ModelView):
], 'Compensation Type')
compensation_amount = fields.Numeric('Compensation Amount ($)', readonly=True, digits=(16, 2))
quantity = fields.Function(fields.Float("Quantity loaded"),'get_qt')
calculation_condition = fields.Function(
fields.Char("Condition Used"), 'get_calculation_info')
calculation_party_role = fields.Function(
fields.Char("Party Role"), 'get_calculation_info')
calculation_responsibility = fields.Function(
fields.Char("Responsibility"), 'get_calculation_info')
calculation_laytime_allowed = fields.Function(
fields.Char("Allowed Laytime Source"), 'get_calculation_info')
calculation_pumping_rate = fields.Function(
fields.Char("Pumping Rate Source"), 'get_calculation_info')
calculation_demurrage_rate = fields.Function(
fields.Char("Demurrage Rate Source"), 'get_calculation_info')
calculation_despatch_rate = fields.Function(
fields.Char("Despatch Rate Source"), 'get_calculation_info')
calculation_start_event = fields.Function(
fields.Char("Start Event"), 'get_calculation_info')
calculation_start_event_time = fields.Function(
fields.Char("Start Event Time"), 'get_calculation_info')
calculation_start_offset = fields.Function(
fields.Char("Start Offset"), 'get_calculation_info')
calculation_end_event = fields.Function(
fields.Char("End Event"), 'get_calculation_info')
calculation_end_event_time = fields.Function(
fields.Char("End Event Time"), 'get_calculation_info')
calculation_rate_used = fields.Function(
fields.Char("Rate Used"), 'get_calculation_info')
notes = fields.Text('Remarks / Notes')
@@ -2329,6 +2383,115 @@ class StatementOfFacts(ModelSQL, ModelView):
self.compensation_type = None
return Decimal(0)
@staticmethod
def _format_calculation_datetime(value):
if value:
return value.strftime('%d/%m/%Y %H:%M')
return ''
@staticmethod
def _format_calculation_number(value):
if value is None:
return ''
decimal = Decimal(str(value)).normalize()
return format(decimal, 'f')
def _calculation_offset_text(self):
condition = getattr(self, 'applied_condition', None)
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:
return '%s %s' % (
self._format_calculation_number(value),
unit or 'hours')
legacy = self.laytime_clause_hour or self.laytime_clause_hour_o
if legacy:
return legacy.strftime('%H:%M:%S')
return ''
def _calculation_rate_text(self, category):
rate = self._condition_rate(category)
if rate is not None:
return '%s / day' % self._format_calculation_number(rate)
if category == 'demurrage':
legacy = self.demurrage_rate_o or self.demurrage_rate
if legacy is not None:
return '%s / day' % self._format_calculation_number(legacy)
return ''
def _calculation_pumping_rate_text(self):
rate = self._condition_rate('pumping')
if rate is not None:
return '%s MT/hour' % self._format_calculation_number(rate)
legacy = self.pumping_rate_o or self.pumping_rate
if legacy:
return '%s MT/hour' % self._format_calculation_number(legacy)
return ''
def _calculation_laytime_allowed_text(self):
condition = getattr(self, 'applied_condition', None)
if condition and condition.laytime_allowed is not None:
value = self._format_calculation_number(condition.laytime_allowed)
return '%s %s' % (value, condition.laytime_unit or 'hours')
pumping_rate = self._calculation_pumping_rate_text()
if pumping_rate:
return 'Computed from pumping rate'
return ''
def _calculation_rate_used_text(self):
balance = Decimal(str(self.laytime_balance or 0))
if balance < 0:
rate = self._calculation_rate_text('demurrage')
if rate:
return 'Demurrage: %s' % rate
if balance > 0:
rate = self._calculation_rate_text('despatch')
if rate:
return 'Despatch: %s' % rate
return ''
def get_calculation_info(self, name):
condition = getattr(self, 'applied_condition', None)
start_event = getattr(condition, 'laytime_start_event', None)
end_event = getattr(condition, 'laytime_end_event', None)
if name == 'calculation_condition':
return getattr(condition, 'rec_name', None) or getattr(
condition, 'name', None) or ''
if name == 'calculation_party_role':
return self._party_role_labels.get(
getattr(condition, 'party_role', None), '')
if name == 'calculation_responsibility':
return self._responsibility_labels.get(
getattr(condition, 'responsibility', None), '')
if name == 'calculation_laytime_allowed':
return self._calculation_laytime_allowed_text()
if name == 'calculation_pumping_rate':
return self._calculation_pumping_rate_text()
if name == 'calculation_demurrage_rate':
return self._calculation_rate_text('demurrage')
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'])
if name == 'calculation_start_event_time':
return self._format_calculation_datetime(
self._laytime_start_base())
if name == 'calculation_start_offset':
return self._calculation_offset_text()
if name == 'calculation_end_event':
return self._event_labels.get(
end_event, self._event_labels['hoses_disconnected'])
if name == 'calculation_end_event_time':
return self._format_calculation_datetime(
self._applied_laytime_end())
if name == 'calculation_rate_used':
return self._calculation_rate_used_text()
return ''
def get_qt(self,name):
if self.shipment:
return self.shipment.get_quantity()

View File

@@ -270,6 +270,52 @@ class PurchaseTradeTestCase(ModuleTestCase):
sof._applied_laytime_end(),
datetime.datetime(2026, 6, 1, 12, 0))
@with_transaction()
def test_sof_calculation_info_explains_structured_inputs(self):
'sof calculation info displays the structured calculation inputs'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
rec_name='Discharge',
name='Discharge',
party_role='owner',
responsibility='ours',
laytime_allowed=Decimal('72'),
laytime_unit='hours',
laytime_start_event='notice_of_readiness',
laytime_start_offset=Decimal('6'),
laytime_start_offset_unit='hours',
laytime_end_event='hoses_disconnected',
laytime_start='Arrival + 1 day',
laytime_end='End pumping',
rates=[
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
Mock(rate=Decimal('12000'), rate_type=Mock(category='despatch')),
])
sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 4, 10, 0)
sof.laytime_balance = -Decimal('12')
self.assertEqual(
sof.get_calculation_info('calculation_condition'), 'Discharge')
self.assertEqual(
sof.get_calculation_info('calculation_start_event'),
'Notice of Readiness')
self.assertEqual(
sof.get_calculation_info('calculation_start_event_time'),
'01/06/2026 02:00')
self.assertEqual(
sof.get_calculation_info('calculation_start_offset'), '6 hours')
self.assertEqual(
sof.get_calculation_info('calculation_end_event'),
'Hoses Disconnected')
self.assertEqual(
sof.get_calculation_info('calculation_end_event_time'),
'04/06/2026 10:00')
self.assertEqual(
sof.get_calculation_info('calculation_rate_used'),
'Demurrage: 24000 / day')
@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

@@ -56,6 +56,39 @@
</page>
<page string="Calculation" id="calcul" col="8">
<separator string="Applied condition" id="calculation_condition_section" colspan="8"/>
<label name="calculation_condition"/>
<field name="calculation_condition" colspan="3"/>
<label name="calculation_party_role"/>
<field name="calculation_party_role"/>
<label name="calculation_responsibility"/>
<field name="calculation_responsibility"/>
<newline/>
<label name="calculation_laytime_allowed"/>
<field name="calculation_laytime_allowed"/>
<label name="calculation_pumping_rate"/>
<field name="calculation_pumping_rate"/>
<label name="calculation_demurrage_rate"/>
<field name="calculation_demurrage_rate"/>
<label name="calculation_despatch_rate"/>
<field name="calculation_despatch_rate"/>
<newline/>
<separator string="Laytime basis" id="calculation_laytime_section" colspan="8"/>
<label name="calculation_start_event"/>
<field name="calculation_start_event"/>
<label name="calculation_start_event_time"/>
<field name="calculation_start_event_time"/>
<label name="calculation_start_offset"/>
<field name="calculation_start_offset"/>
<newline/>
<label name="calculation_end_event"/>
<field name="calculation_end_event"/>
<label name="calculation_end_event_time"/>
<field name="calculation_end_event_time"/>
<label name="calculation_rate_used"/>
<field name="calculation_rate_used"/>
<newline/>
<separator string="Result" id="calculation_result_section" colspan="8"/>
<label name="quantity"/>
<field name="quantity" colspan="7"/>
<newline/>