diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index ffd2511..f52d2fe 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -141,10 +141,11 @@ def register():
party.PartyExecutionPlace,
payment_term.PaymentTerm,
payment_term.PaymentTermLine,
- stock.CharterRateType,
- stock.CharterConditionRate,
- stock.CharterCondition,
- stock.CharterParty,
+ stock.CharterRateType,
+ stock.CharterConditionRate,
+ stock.CharterConditionLaytimeStartRule,
+ stock.CharterCondition,
+ stock.CharterParty,
purchase.ContractClause,
purchase.ContractTemplate,
purchase.ContractTemplateLine,
diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py
index 0ce1be1..e8abb5c 100755
--- a/modules/purchase_trade/stock.py
+++ b/modules/purchase_trade/stock.py
@@ -29,9 +29,21 @@ from trytond.exceptions import UserError
from trytond.modules.stock.shipment import SupplierShipping as BaseSupplierShipping
-logger = logging.getLogger(__name__)
-
-class Location(metaclass=PoolMeta):
+logger = logging.getLogger(__name__)
+
+CHARTER_LAYTIME_EVENT_SELECTION = [
+ (None, ''),
+ ('notice_of_readiness', 'Notice of Readiness'),
+ ('arrival', 'Arrival'),
+ ('all_fast', 'All Fast'),
+ ('hoses_connected', 'Hoses Connected'),
+ ('start_pumping', 'Start Pumping'),
+ ('end_pumping', 'End Pumping'),
+ ('hoses_disconnected', 'Hoses Disconnected'),
+ ('sailing', 'Sailing'),
+]
+
+class Location(metaclass=PoolMeta):
__name__ = 'stock.location'
def get_places(self):
@@ -519,6 +531,31 @@ class CharterConditionRate(ModelSQL, ModelView):
self.basis = self.rate_type.default_basis
+class CharterConditionLaytimeStartRule(ModelSQL, ModelView):
+ "Charter Condition Laytime Start Rule"
+ __name__ = 'charter.condition.laytime.start.rule'
+
+ condition = fields.Many2One(
+ 'charter.condition', "Condition", required=True, ondelete='CASCADE')
+ sequence = fields.Integer("Sequence")
+ event = fields.Selection(
+ CHARTER_LAYTIME_EVENT_SELECTION, "Event", required=True)
+ offset = fields.Numeric("Offset", digits=(16, 4))
+ offset_unit = fields.Selection([
+ (None, ''),
+ ('hours', 'Hours'),
+ ('days', 'Days'),
+ ], "Offset Unit")
+
+ @staticmethod
+ def default_sequence():
+ return 10
+
+ @staticmethod
+ def default_offset_unit():
+ return 'hours'
+
+
class CharterCondition(ModelSQL, ModelView):
"Charter Condition"
__name__ = 'charter.condition'
@@ -568,16 +605,13 @@ class CharterCondition(ModelSQL, ModelView):
laytime_start = fields.Text("Laytime Start")
laytime_end = fields.Text("Laytime End")
nor_clause = fields.Text("NOR Clause")
- laytime_start_event = fields.Selection([
- (None, ''),
- ('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'),
- ], "Laytime Start Event")
+ laytime_start_rule = fields.Selection([
+ ('simple', 'Single Event + Offset'),
+ ('earliest_of', 'Earliest Of'),
+ ('latest_of', 'Latest Of'),
+ ], "Laytime Start Rule")
+ laytime_start_event = fields.Selection(
+ CHARTER_LAYTIME_EVENT_SELECTION, "Laytime Start Event")
laytime_start_offset = fields.Numeric(
"Laytime Start Offset", digits=(16, 4))
laytime_start_offset_unit = fields.Selection([
@@ -585,16 +619,11 @@ class CharterCondition(ModelSQL, ModelView):
('hours', 'Hours'),
('days', 'Days'),
], "Laytime Start Offset Unit")
- laytime_end_event = fields.Selection([
- (None, ''),
- ('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'),
- ], "Laytime End Event")
+ laytime_start_rules = fields.One2Many(
+ 'charter.condition.laytime.start.rule', 'condition',
+ "Laytime Start Candidates")
+ laytime_end_event = fields.Selection(
+ CHARTER_LAYTIME_EVENT_SELECTION, "Laytime End Event")
turn_time = fields.Numeric("Turn Time", digits=(16, 4))
turn_time_unit = fields.Selection([
(None, ''),
@@ -618,6 +647,10 @@ class CharterCondition(ModelSQL, ModelView):
def default_all_time_saved():
return False
+ @staticmethod
+ def default_laytime_start_rule():
+ return 'simple'
+
@staticmethod
def default_laytime_start_event():
return 'notice_of_readiness'
@@ -3109,6 +3142,7 @@ class StatementOfFacts(ModelSQL, ModelView):
_event_labels = {
'notice_of_readiness': 'Notice of Readiness',
'arrival': 'Arrival',
+ 'all_fast': 'All Fast',
'hoses_connected': 'Hoses Connected',
'start_pumping': 'Start Pumping',
'end_pumping': 'End Pumping',
@@ -3138,6 +3172,7 @@ class StatementOfFacts(ModelSQL, ModelView):
'arrival_time': ('arrival_date', 'arrival_hour'),
'notice_of_readiness_time': (
'notice_of_readiness_date', 'notice_of_readiness_hour'),
+ 'all_fast': ('all_fast_date', 'all_fast_hour'),
'hoses_connected': ('hoses_connected_date', 'hoses_connected_hour'),
'start_pumping': ('start_pumping_date', 'start_pumping_hour'),
'end_pumping': ('end_pumping_date', 'end_pumping_hour'),
@@ -3206,6 +3241,9 @@ class StatementOfFacts(ModelSQL, ModelView):
arrival_time = fields.DateTime('Arrival Time')
arrival_date = fields.Date('Arrival Date')
arrival_hour = fields.Time('Arrival Time')
+ all_fast = fields.DateTime('All Fast')
+ all_fast_date = fields.Date('All Fast Date')
+ all_fast_hour = fields.Time('All Fast Time')
hoses_connected = fields.DateTime('Hoses Connected')
hoses_connected_date = fields.Date('Hoses Connected Date')
hoses_connected_hour = fields.Time('Hoses Connected Time')
@@ -3439,10 +3477,7 @@ class StatementOfFacts(ModelSQL, ModelView):
value = getattr(condition, 'turn_time', None)
unit = getattr(condition, 'turn_time_unit', None)
if value is not None:
- hours = Decimal(str(value))
- if unit == 'days':
- hours *= Decimal(24)
- return datetime.timedelta(hours=float(hours))
+ return self._offset_delta(value, unit)
if self.laytime_clause_hour:
t = self.laytime_clause_hour
@@ -3453,10 +3488,20 @@ class StatementOfFacts(ModelSQL, ModelView):
return datetime.timedelta(
hours=t.hour, minutes=t.minute, seconds=t.second)
+ @staticmethod
+ def _offset_delta(value, unit):
+ if value is None:
+ return datetime.timedelta(0)
+ hours = Decimal(str(value))
+ if unit == 'days':
+ hours *= Decimal(24)
+ return datetime.timedelta(hours=float(hours))
+
def _condition_event_datetime(self, event):
return {
'notice_of_readiness': self.notice_of_readiness_time,
'arrival': self.arrival_time,
+ 'all_fast': self.all_fast,
'hoses_connected': self.hoses_connected,
'start_pumping': self.start_pumping,
'end_pumping': self.end_pumping,
@@ -3464,8 +3509,26 @@ class StatementOfFacts(ModelSQL, ModelView):
'sailing': self.sailing_time,
}.get(event)
+ def _laytime_start_rule_candidates(self):
+ condition = getattr(self, 'applied_condition', None)
+ rules = getattr(condition, 'laytime_start_rules', None) or []
+ candidates = []
+ for rule in sorted(rules, key=lambda r: getattr(r, 'sequence', 0) or 0):
+ base = self._condition_event_datetime(getattr(rule, 'event', None))
+ if not base:
+ continue
+ candidates.append(base + self._offset_delta(
+ getattr(rule, 'offset', None),
+ getattr(rule, 'offset_unit', None)))
+ return candidates
+
def _laytime_start_base(self):
condition = getattr(self, 'applied_condition', None)
+ rule = getattr(condition, 'laytime_start_rule', 'simple')
+ if rule in {'earliest_of', 'latest_of'}:
+ candidates = self._laytime_start_rule_candidates()
+ if candidates:
+ return min(candidates) if rule == 'earliest_of' else max(candidates)
event = getattr(condition, 'laytime_start_event', None)
base = self._condition_event_datetime(event)
if base:
@@ -3476,6 +3539,10 @@ class StatementOfFacts(ModelSQL, ModelView):
base = self._laytime_start_base()
if not base:
return
+ condition = getattr(self, 'applied_condition', None)
+ if getattr(condition, 'laytime_start_rule', 'simple') in {
+ 'earliest_of', 'latest_of'}:
+ return base
return base + self._applied_turn_delta()
def _applied_laytime_end(self):
@@ -3517,6 +3584,9 @@ 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'}:
+ return self._calculation_start_candidates_text()
value = getattr(condition, 'laytime_start_offset', None)
unit = getattr(condition, 'laytime_start_offset_unit', None)
if value is None:
@@ -3531,6 +3601,33 @@ class StatementOfFacts(ModelSQL, ModelView):
return legacy.strftime('%H:%M:%S')
return ''
+ def _calculation_start_rule_text(self):
+ condition = getattr(self, 'applied_condition', None)
+ rule = getattr(condition, 'laytime_start_rule', 'simple')
+ if rule == 'earliest_of':
+ return 'Earliest Of'
+ if rule == 'latest_of':
+ return 'Latest Of'
+ start_event = getattr(condition, 'laytime_start_event', None)
+ return self._event_labels.get(
+ start_event, self._event_labels['notice_of_readiness'])
+
+ def _calculation_start_candidates_text(self):
+ condition = getattr(self, 'applied_condition', None)
+ rules = getattr(condition, 'laytime_start_rules', None) or []
+ parts = []
+ for rule in sorted(rules, key=lambda r: getattr(r, 'sequence', 0) or 0):
+ label = self._event_labels.get(getattr(rule, 'event', None), '')
+ offset = getattr(rule, 'offset', None)
+ if offset is not None:
+ label = '%s + %s %s' % (
+ label,
+ self._format_calculation_number(offset),
+ getattr(rule, 'offset_unit', None) or 'hours')
+ if label:
+ parts.append(label)
+ return '; '.join(parts)
+
def _calculation_rate_text(self, category):
rate = self._condition_rate(category)
if rate is not None:
@@ -3594,8 +3691,7 @@ class StatementOfFacts(ModelSQL, ModelView):
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'])
+ return self._calculation_start_rule_text()
if name == 'calculation_start_event_time':
return self._format_calculation_datetime(
self._laytime_start_base())
diff --git a/modules/purchase_trade/stock.xml b/modules/purchase_trade/stock.xml
index 50720cc..2e3ce9e 100755
--- a/modules/purchase_trade/stock.xml
+++ b/modules/purchase_trade/stock.xml
@@ -76,6 +76,16 @@ this repository contains the full copyright notices and license terms. -->
form
charter_condition_rate_form
+
+ charter.condition.laytime.start.rule
+ tree
+ charter_condition_laytime_start_rule_tree
+
+
+ charter.condition.laytime.start.rule
+ form
+ charter_condition_laytime_start_rule_form
+
charter.condition
tree
@@ -164,6 +174,13 @@ this repository contains the full copyright notices and license terms. -->
+
+ charter.condition.laytime.start.rule
+
+
+
+
+
stock.charter.party
@@ -196,6 +213,14 @@ this repository contains the full copyright notices and license terms. -->
+
+ charter.condition.laytime.start.rule
+
+
+
+
+
+
🚢 Find vessel
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index a7ea05e..0d2eda8 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -1502,6 +1502,38 @@ class PurchaseTradeTestCase(ModuleTestCase):
sof._applied_laytime_start(),
datetime.datetime(2026, 6, 1, 8, 0))
+ @with_transaction()
+ def test_sof_laytime_start_uses_earliest_candidate(self):
+ 'sof laytime start can use the earliest of multiple candidates'
+ Sof = Pool().get('sof.statement')
+ sof = Sof()
+ sof.applied_condition = SimpleNamespace(
+ laytime_start_rule='earliest_of',
+ laytime_start_rules=[
+ SimpleNamespace(
+ sequence=10,
+ event='notice_of_readiness',
+ offset=Decimal('6'),
+ offset_unit='hours'),
+ SimpleNamespace(
+ sequence=20,
+ event='all_fast',
+ offset=None,
+ offset_unit='hours'),
+ ])
+ sof.notice_of_readiness_time = datetime.datetime(2026, 6, 1, 2, 0)
+ sof.all_fast = datetime.datetime(2026, 6, 1, 7, 0)
+
+ self.assertEqual(
+ sof._applied_laytime_start(),
+ datetime.datetime(2026, 6, 1, 7, 0))
+ self.assertEqual(
+ sof.get_calculation_info('calculation_start_event'),
+ 'Earliest Of')
+ self.assertEqual(
+ sof.get_calculation_info('calculation_start_offset'),
+ 'Notice of Readiness + 6 hours; All Fast')
+
@with_transaction()
def test_sof_laytime_end_reads_structured_condition_rule(self):
'sof laytime end reads the structured end event'
@@ -1593,11 +1625,16 @@ class PurchaseTradeTestCase(ModuleTestCase):
values = Sof._sync_datetime_values({
'notice_of_readiness_date': datetime.date(2026, 7, 6),
'notice_of_readiness_hour': datetime.time(8, 0),
+ 'all_fast_date': datetime.date(2026, 7, 6),
+ 'all_fast_hour': datetime.time(10, 30),
})
self.assertEqual(
values['notice_of_readiness_time'],
datetime.datetime(2026, 7, 6, 8, 0))
+ self.assertEqual(
+ values['all_fast'],
+ datetime.datetime(2026, 7, 6, 10, 30))
@with_transaction()
def test_sof_event_date_time_values_sync_to_datetime(self):
diff --git a/modules/purchase_trade/view/charter_condition_form.xml b/modules/purchase_trade/view/charter_condition_form.xml
index 77171da..b8f08b6 100644
--- a/modules/purchase_trade/view/charter_condition_form.xml
+++ b/modules/purchase_trade/view/charter_condition_form.xml
@@ -20,6 +20,9 @@
+
+
+
@@ -28,6 +31,9 @@
+
+
diff --git a/modules/purchase_trade/view/charter_condition_laytime_start_rule_form.xml b/modules/purchase_trade/view/charter_condition_laytime_start_rule_form.xml
new file mode 100644
index 0000000..d6d5306
--- /dev/null
+++ b/modules/purchase_trade/view/charter_condition_laytime_start_rule_form.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/modules/purchase_trade/view/charter_condition_laytime_start_rule_tree.xml b/modules/purchase_trade/view/charter_condition_laytime_start_rule_tree.xml
new file mode 100644
index 0000000..95af2d7
--- /dev/null
+++ b/modules/purchase_trade/view/charter_condition_laytime_start_rule_tree.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/charter_condition_tree.xml b/modules/purchase_trade/view/charter_condition_tree.xml
index 3f27b78..8425e2b 100644
--- a/modules/purchase_trade/view/charter_condition_tree.xml
+++ b/modules/purchase_trade/view/charter_condition_tree.xml
@@ -4,6 +4,7 @@
+
diff --git a/modules/purchase_trade/view/sof_form.xml b/modules/purchase_trade/view/sof_form.xml
index fb64dae..df3644f 100755
--- a/modules/purchase_trade/view/sof_form.xml
+++ b/modules/purchase_trade/view/sof_form.xml
@@ -28,6 +28,11 @@
+
+
+
+
+