diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index 45fa386..7a95fc7 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -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() diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 288a8e1..5d52564 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -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' diff --git a/modules/purchase_trade/view/sof_form.xml b/modules/purchase_trade/view/sof_form.xml index 03ea310..e4e59e8 100755 --- a/modules/purchase_trade/view/sof_form.xml +++ b/modules/purchase_trade/view/sof_form.xml @@ -56,6 +56,39 @@ + +