Support multiple laytime start candidates

This commit is contained in:
2026-07-12 11:52:28 +02:00
parent 8719a63fa5
commit 47d0f025b3
9 changed files with 223 additions and 33 deletions

View File

@@ -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,

View File

@@ -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())

View File

@@ -76,6 +76,16 @@ this repository contains the full copyright notices and license terms. -->
<field name="type">form</field>
<field name="name">charter_condition_rate_form</field>
</record>
<record model="ir.ui.view" id="charter_condition_laytime_start_rule_view_tree">
<field name="model">charter.condition.laytime.start.rule</field>
<field name="type">tree</field>
<field name="name">charter_condition_laytime_start_rule_tree</field>
</record>
<record model="ir.ui.view" id="charter_condition_laytime_start_rule_view_form">
<field name="model">charter.condition.laytime.start.rule</field>
<field name="type">form</field>
<field name="name">charter_condition_laytime_start_rule_form</field>
</record>
<record model="ir.ui.view" id="charter_condition_view_tree">
<field name="model">charter.condition</field>
<field name="type">tree</field>
@@ -164,6 +174,13 @@ this repository contains the full copyright notices and license terms. -->
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.access" id="access_charter_condition_laytime_start_rule">
<field name="model">charter.condition.laytime.start.rule</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.access" id="access_charter_party_group">
<field name="model">stock.charter.party</field>
<field name="group" ref="purchase.group_purchase"/>
@@ -196,6 +213,14 @@ this repository contains the full copyright notices and license terms. -->
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.access" id="access_charter_condition_laytime_start_rule_group">
<field name="model">charter.condition.laytime.start.rule</field>
<field name="group" ref="purchase.group_purchase"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.action.wizard" id="act_vf">
<field name="name">🚢 Find vessel</field>

View File

@@ -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):

View File

@@ -20,6 +20,9 @@
<field name="all_time_saved"/>
</page>
<page string="Laytime" col="4" id="laytime">
<label name="laytime_start_rule"/>
<field name="laytime_start_rule"/>
<newline/>
<label name="laytime_start_event"/>
<field name="laytime_start_event"/>
<label name="laytime_end_event"/>
@@ -28,6 +31,9 @@
<field name="laytime_start_offset"/>
<label name="laytime_start_offset_unit"/>
<field name="laytime_start_offset_unit"/>
<separator string="Start candidates" colspan="4"/>
<field name="laytime_start_rules" colspan="4" mode="tree,form"
view_ids="purchase_trade.charter_condition_laytime_start_rule_view_tree,purchase_trade.charter_condition_laytime_start_rule_view_form"/>
<label name="laytime_clause"/>
<field name="laytime_clause" colspan="3"/>
<label name="laytime_start"/>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<form col="4">
<label name="sequence"/>
<field name="sequence"/>
<newline/>
<label name="event"/>
<field name="event"/>
<label name="offset"/>
<field name="offset"/>
<label name="offset_unit"/>
<field name="offset_unit"/>
</form>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<tree sequence="sequence">
<field name="sequence" optional="1"/>
<field name="event"/>
<field name="offset"/>
<field name="offset_unit"/>
</tree>

View File

@@ -4,6 +4,7 @@
<field name="party"/>
<field name="party_role"/>
<field name="responsibility"/>
<field name="laytime_start_rule"/>
<field name="laytime_start_event"/>
<field name="laytime_start_offset"/>
<field name="laytime_start_offset_unit"/>

View File

@@ -28,6 +28,11 @@
<label name="notice_of_readiness_hour"/>
<field name="notice_of_readiness_hour"/>
<newline/>
<label name="all_fast_date"/>
<field name="all_fast_date"/>
<label name="all_fast_hour"/>
<field name="all_fast_hour"/>
<newline/>
<label name="hoses_connected_date"/>
<field name="hoses_connected_date"/>
<label name="hoses_connected_hour"/>