Add SOF calculation overrides

This commit is contained in:
2026-07-12 13:10:13 +02:00
parent 47d0f025b3
commit ddd88cd840
3 changed files with 146 additions and 29 deletions

View File

@@ -39,7 +39,10 @@ CHARTER_LAYTIME_EVENT_SELECTION = [
('hoses_connected', 'Hoses Connected'),
('start_pumping', 'Start Pumping'),
('end_pumping', 'End Pumping'),
('completed_loading', 'Completed Loading'),
('completed_discharge', 'Completed Discharge'),
('hoses_disconnected', 'Hoses Disconnected'),
('documents_on_board', 'Documents on Board'),
('sailing', 'Sailing'),
]
@@ -2539,7 +2542,8 @@ class ShipmentIn(metaclass=PoolMeta):
laytime_start = s._applied_laytime_start()
if laytime_start:
s.laytime_commenced = laytime_start
quantity = float(s.quantity or sh.get_quantity() or 0)
quantity = float(s._calculation_quantity(
s.quantity or sh.get_quantity() or 0))
s.laytime_allowed = s._applied_laytime_allowed(quantity)
s.laytime_completed = s._applied_laytime_end()
#s.laytime_completed = s.laytime_commenced + datetime.timedelta(hours=s.laytime_allowed)
@@ -3146,7 +3150,10 @@ class StatementOfFacts(ModelSQL, ModelView):
'hoses_connected': 'Hoses Connected',
'start_pumping': 'Start Pumping',
'end_pumping': 'End Pumping',
'completed_loading': 'Completed Loading',
'completed_discharge': 'Completed Discharge',
'hoses_disconnected': 'Hoses Disconnected',
'documents_on_board': 'Documents on Board',
'sailing': 'Sailing',
}
_party_role_labels = {
@@ -3176,8 +3183,14 @@ class StatementOfFacts(ModelSQL, ModelView):
'hoses_connected': ('hoses_connected_date', 'hoses_connected_hour'),
'start_pumping': ('start_pumping_date', 'start_pumping_hour'),
'end_pumping': ('end_pumping_date', 'end_pumping_hour'),
'completed_loading': (
'completed_loading_date', 'completed_loading_hour'),
'completed_discharge': (
'completed_discharge_date', 'completed_discharge_hour'),
'hoses_disconnected': (
'hoses_disconnected_date', 'hoses_disconnected_hour'),
'documents_on_board': (
'documents_on_board_date', 'documents_on_board_hour'),
'sailing_time': ('sailing_date', 'sailing_hour'),
'laytime_commenced': (
'laytime_commenced_date', 'laytime_commenced_hour'),
@@ -3200,8 +3213,15 @@ class StatementOfFacts(ModelSQL, ModelView):
laytime_clause_hour = fields.Time("")
laytime_start = fields.DateTime('Laytime Start')
laytime_end = fields.DateTime('Laytime End')
demurrage_rate = fields.Numeric('Demurrage Rate ($/day)', digits=(16, 2))
pumping_rate = fields.Float('Pumping Rate (MT/hour)')
demurrage_rate = fields.Numeric('Demurrage Rate ($/day)', digits=(16, 2))
pumping_rate = fields.Float('Pumping Rate (MT/hour)')
demurrage_rate_override = fields.Numeric(
'Demurrage Rate Override ($/day)', digits=(16, 2))
despatch_rate_override = fields.Numeric(
'Despatch Rate Override ($/day)', digits=(16, 2))
pumping_rate_override = fields.Numeric(
'Pumping Rate Override (MT/hour)', digits=(16, 4))
additional_quantity = fields.Numeric('Additional Qt', digits=(16, 4))
laytime_type_o = fields.Selection([
('nor', 'NOR +'),
@@ -3253,9 +3273,18 @@ class StatementOfFacts(ModelSQL, ModelView):
end_pumping = fields.DateTime('End Pumping')
end_pumping_date = fields.Date('End Pumping Date')
end_pumping_hour = fields.Time('End Pumping Time')
completed_loading = fields.DateTime('Completed Loading')
completed_loading_date = fields.Date('Completed Loading Date')
completed_loading_hour = fields.Time('Completed Loading Time')
completed_discharge = fields.DateTime('Completed Discharge')
completed_discharge_date = fields.Date('Completed Discharge Date')
completed_discharge_hour = fields.Time('Completed Discharge Time')
hoses_disconnected = fields.DateTime('Hoses Disconnected')
hoses_disconnected_date = fields.Date('Hoses Disconnected Date')
hoses_disconnected_hour = fields.Time('Hoses Disconnected Time')
documents_on_board = fields.DateTime('Documents on Board')
documents_on_board_date = fields.Date('Documents on Board Date')
documents_on_board_hour = fields.Time('Documents on Board Time')
sailing_time = fields.DateTime('Sailing Time')
sailing_date = fields.Date('Sailing Date')
sailing_hour = fields.Time('Sailing Time')
@@ -3451,6 +3480,32 @@ class StatementOfFacts(ModelSQL, ModelView):
if self._rate_category(rate) == category and rate.rate is not None:
return Decimal(str(rate.rate))
def _rate_override(self, category):
override_fields = {
'demurrage': 'demurrage_rate_override',
'despatch': 'despatch_rate_override',
'pumping': 'pumping_rate_override',
}
value = getattr(self, override_fields.get(category, ''), None)
if value is not None:
return Decimal(str(value))
def _applied_rate(self, category):
override = self._rate_override(category)
if override is not None:
return override
rate = self._condition_rate(category)
if rate is not None:
return rate
if category == 'demurrage':
legacy = self.demurrage_rate_o or self.demurrage_rate
if legacy is not None:
return Decimal(str(legacy))
if category == 'pumping':
legacy = self.pumping_rate_o or self.pumping_rate
if legacy:
return Decimal(str(legacy))
def _applied_laytime_allowed(self, quantity):
condition = getattr(self, 'applied_condition', None)
if condition and condition.laytime_allowed is not None:
@@ -3460,14 +3515,10 @@ class StatementOfFacts(ModelSQL, ModelView):
allowed *= Decimal(24)
return float(allowed)
pumping_rate = self._condition_rate('pumping')
pumping_rate = self._applied_rate('pumping')
if pumping_rate:
return round(float(Decimal(str(quantity or 0)) / pumping_rate), 2)
legacy_pumping = self.pumping_rate_o or self.pumping_rate
if legacy_pumping:
return round(float(quantity or 0) / float(legacy_pumping), 2)
def _applied_turn_delta(self):
condition = getattr(self, 'applied_condition', None)
if condition:
@@ -3505,7 +3556,10 @@ class StatementOfFacts(ModelSQL, ModelView):
'hoses_connected': self.hoses_connected,
'start_pumping': self.start_pumping,
'end_pumping': self.end_pumping,
'completed_loading': self.completed_loading,
'completed_discharge': self.completed_discharge,
'hoses_disconnected': self.hoses_disconnected,
'documents_on_board': self.documents_on_board,
'sailing': self.sailing_time,
}.get(event)
@@ -3525,6 +3579,8 @@ class StatementOfFacts(ModelSQL, ModelView):
def _laytime_start_base(self):
condition = getattr(self, 'applied_condition', None)
rule = getattr(condition, 'laytime_start_rule', 'simple')
if rule not in {'earliest_of', 'latest_of'}:
rule = 'simple'
if rule in {'earliest_of', 'latest_of'}:
candidates = self._laytime_start_rule_candidates()
if candidates:
@@ -3540,8 +3596,8 @@ class StatementOfFacts(ModelSQL, ModelView):
if not base:
return
condition = getattr(self, 'applied_condition', None)
if getattr(condition, 'laytime_start_rule', 'simple') in {
'earliest_of', 'latest_of'}:
rule = getattr(condition, 'laytime_start_rule', 'simple')
if rule in {'earliest_of', 'latest_of'}:
return base
return base + self._applied_turn_delta()
@@ -3557,12 +3613,10 @@ class StatementOfFacts(ModelSQL, ModelView):
balance = Decimal(str(self.laytime_balance or 0))
if balance < 0:
self.compensation_type = 'demurrage'
rate = self._condition_rate('demurrage')
if rate is None:
rate = Decimal(str(self.demurrage_rate_o or self.demurrage_rate or 0))
rate = self._applied_rate('demurrage') or Decimal(0)
return round(balance * rate / Decimal(24), 2)
if balance > 0:
rate = self._condition_rate('despatch')
rate = self._applied_rate('despatch')
if rate is not None:
self.compensation_type = 'despatch'
return round(balance * rate / Decimal(24), 2)
@@ -3584,8 +3638,8 @@ 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'}:
rule = getattr(condition, 'laytime_start_rule', 'simple')
if rule 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)
@@ -3629,22 +3683,15 @@ class StatementOfFacts(ModelSQL, ModelView):
return '; '.join(parts)
def _calculation_rate_text(self, category):
rate = self._condition_rate(category)
rate = self._applied_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')
rate = self._applied_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):
@@ -3710,7 +3757,13 @@ class StatementOfFacts(ModelSQL, ModelView):
def get_qt(self,name):
if self.shipment:
return self.shipment.get_quantity()
def _calculation_quantity(self, base_quantity=None):
if base_quantity is None:
base_quantity = self.quantity
return Decimal(str(base_quantity or 0)) + Decimal(str(
self.additional_quantity or 0))
class SoFEvent(ModelSQL, ModelView):
"Event from Statement of Facts"
__name__ = 'sof.event'

View File

@@ -1457,6 +1457,30 @@ class PurchaseTradeTestCase(ModuleTestCase):
sof._applied_compensation_amount(), Decimal('-12000.00'))
self.assertEqual(sof.compensation_type, 'demurrage')
@with_transaction()
def test_sof_calculation_rate_overrides_win_over_condition_rates(self):
'sof calculation rate overrides are used before condition rates'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(
laytime_allowed=None,
rates=[
Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')),
Mock(rate=Decimal('100'), rate_type=Mock(category='pumping')),
])
sof.demurrage_rate_override = Decimal('36000')
sof.pumping_rate_override = Decimal('200')
sof.laytime_balance = -Decimal('12')
self.assertEqual(
sof._applied_laytime_allowed(Decimal('1000')), 5.0)
self.assertEqual(
sof._applied_compensation_amount(), Decimal('-18000.00'))
self.assertEqual(sof.compensation_type, 'demurrage')
self.assertEqual(
sof.get_calculation_info('calculation_demurrage_rate'),
'36000 / day')
@with_transaction()
def test_sof_available_conditions_include_shipment_contracts(self):
'sof applied condition choices include owner, purchase and sale terms'
@@ -1539,13 +1563,25 @@ class PurchaseTradeTestCase(ModuleTestCase):
'sof laytime end reads the structured end event'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.applied_condition = Mock(laytime_end_event='hoses_disconnected')
sof.applied_condition = Mock(laytime_end_event='documents_on_board')
sof.end_pumping = datetime.datetime(2026, 6, 1, 12, 0)
sof.hoses_disconnected = datetime.datetime(2026, 6, 1, 13, 0)
sof.documents_on_board = datetime.datetime(2026, 6, 1, 14, 0)
self.assertEqual(
sof._applied_laytime_end(),
datetime.datetime(2026, 6, 1, 13, 0))
datetime.datetime(2026, 6, 1, 14, 0))
@with_transaction()
def test_sof_additional_quantity_is_added_to_calculation_quantity(self):
'sof additional quantity is added to the quantity used for calculation'
Sof = Pool().get('sof.statement')
sof = Sof()
sof.additional_quantity = Decimal('25')
self.assertEqual(
sof._calculation_quantity(Decimal('100')),
Decimal('125'))
@with_transaction()
def test_sof_laytime_ignores_free_text_clauses(self):
@@ -1627,6 +1663,8 @@ class PurchaseTradeTestCase(ModuleTestCase):
'notice_of_readiness_hour': datetime.time(8, 0),
'all_fast_date': datetime.date(2026, 7, 6),
'all_fast_hour': datetime.time(10, 30),
'documents_on_board_date': datetime.date(2026, 7, 6),
'documents_on_board_hour': datetime.time(12, 0),
})
self.assertEqual(
@@ -1635,6 +1673,9 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(
values['all_fast'],
datetime.datetime(2026, 7, 6, 10, 30))
self.assertEqual(
values['documents_on_board'],
datetime.datetime(2026, 7, 6, 12, 0))
@with_transaction()
def test_sof_event_date_time_values_sync_to_datetime(self):

View File

@@ -46,10 +46,24 @@
<field name="end_pumping_date"/>
<label name="end_pumping_hour"/>
<field name="end_pumping_hour"/>
<newline/>
<label name="completed_loading_date"/>
<field name="completed_loading_date"/>
<label name="completed_loading_hour"/>
<field name="completed_loading_hour"/>
<label name="completed_discharge_date"/>
<field name="completed_discharge_date"/>
<label name="completed_discharge_hour"/>
<field name="completed_discharge_hour"/>
<newline/>
<label name="hoses_disconnected_date"/>
<field name="hoses_disconnected_date"/>
<label name="hoses_disconnected_hour"/>
<field name="hoses_disconnected_hour"/>
<label name="documents_on_board_date"/>
<field name="documents_on_board_date"/>
<label name="documents_on_board_hour"/>
<field name="documents_on_board_hour"/>
<newline/>
<label name="sailing_date"/>
<field name="sailing_date"/>
@@ -77,6 +91,13 @@
<label name="calculation_despatch_rate"/>
<field name="calculation_despatch_rate"/>
<newline/>
<label name="demurrage_rate_override"/>
<field name="demurrage_rate_override"/>
<label name="despatch_rate_override"/>
<field name="despatch_rate_override"/>
<label name="pumping_rate_override"/>
<field name="pumping_rate_override"/>
<newline/>
<separator string="Laytime basis" id="calculation_laytime_section" colspan="8"/>
<label name="calculation_start_event"/>
<field name="calculation_start_event"/>
@@ -94,7 +115,9 @@
<newline/>
<separator string="Result" id="calculation_result_section" colspan="8"/>
<label name="quantity"/>
<field name="quantity" colspan="7"/>
<field name="quantity"/>
<label name="additional_quantity"/>
<field name="additional_quantity"/>
<newline/>
<label name="laytime_commenced_date"/>
<field name="laytime_commenced_date"/>