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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -77,6 +91,13 @@
+
+
+
+
+
+
+
@@ -94,7 +115,9 @@
-
+
+
+