From ddd88cd840fe293c108871d58d062283682efc59 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sun, 12 Jul 2026 13:10:13 +0200 Subject: [PATCH] Add SOF calculation overrides --- modules/purchase_trade/stock.py | 105 +++++++++++++++----- modules/purchase_trade/tests/test_module.py | 45 ++++++++- modules/purchase_trade/view/sof_form.xml | 25 ++++- 3 files changed, 146 insertions(+), 29 deletions(-) diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index e8abb5c..5c1ff4a 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -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' diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 0d2eda8..ffd148e 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -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): diff --git a/modules/purchase_trade/view/sof_form.xml b/modules/purchase_trade/view/sof_form.xml index df3644f..c04c1be 100755 --- a/modules/purchase_trade/view/sof_form.xml +++ b/modules/purchase_trade/view/sof_form.xml @@ -46,10 +46,24 @@