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, party.PartyExecutionPlace,
payment_term.PaymentTerm, payment_term.PaymentTerm,
payment_term.PaymentTermLine, payment_term.PaymentTermLine,
stock.CharterRateType, stock.CharterRateType,
stock.CharterConditionRate, stock.CharterConditionRate,
stock.CharterCondition, stock.CharterConditionLaytimeStartRule,
stock.CharterParty, stock.CharterCondition,
stock.CharterParty,
purchase.ContractClause, purchase.ContractClause,
purchase.ContractTemplate, purchase.ContractTemplate,
purchase.ContractTemplateLine, purchase.ContractTemplateLine,

View File

@@ -29,9 +29,21 @@ from trytond.exceptions import UserError
from trytond.modules.stock.shipment import SupplierShipping as BaseSupplierShipping from trytond.modules.stock.shipment import SupplierShipping as BaseSupplierShipping
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Location(metaclass=PoolMeta): 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' __name__ = 'stock.location'
def get_places(self): def get_places(self):
@@ -519,6 +531,31 @@ class CharterConditionRate(ModelSQL, ModelView):
self.basis = self.rate_type.default_basis 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): class CharterCondition(ModelSQL, ModelView):
"Charter Condition" "Charter Condition"
__name__ = 'charter.condition' __name__ = 'charter.condition'
@@ -568,16 +605,13 @@ class CharterCondition(ModelSQL, ModelView):
laytime_start = fields.Text("Laytime Start") laytime_start = fields.Text("Laytime Start")
laytime_end = fields.Text("Laytime End") laytime_end = fields.Text("Laytime End")
nor_clause = fields.Text("NOR Clause") nor_clause = fields.Text("NOR Clause")
laytime_start_event = fields.Selection([ laytime_start_rule = fields.Selection([
(None, ''), ('simple', 'Single Event + Offset'),
('notice_of_readiness', 'Notice of Readiness'), ('earliest_of', 'Earliest Of'),
('arrival', 'Arrival'), ('latest_of', 'Latest Of'),
('hoses_connected', 'Hoses Connected'), ], "Laytime Start Rule")
('start_pumping', 'Start Pumping'), laytime_start_event = fields.Selection(
('end_pumping', 'End Pumping'), CHARTER_LAYTIME_EVENT_SELECTION, "Laytime Start Event")
('hoses_disconnected', 'Hoses Disconnected'),
('sailing', 'Sailing'),
], "Laytime Start Event")
laytime_start_offset = fields.Numeric( laytime_start_offset = fields.Numeric(
"Laytime Start Offset", digits=(16, 4)) "Laytime Start Offset", digits=(16, 4))
laytime_start_offset_unit = fields.Selection([ laytime_start_offset_unit = fields.Selection([
@@ -585,16 +619,11 @@ class CharterCondition(ModelSQL, ModelView):
('hours', 'Hours'), ('hours', 'Hours'),
('days', 'Days'), ('days', 'Days'),
], "Laytime Start Offset Unit") ], "Laytime Start Offset Unit")
laytime_end_event = fields.Selection([ laytime_start_rules = fields.One2Many(
(None, ''), 'charter.condition.laytime.start.rule', 'condition',
('notice_of_readiness', 'Notice of Readiness'), "Laytime Start Candidates")
('arrival', 'Arrival'), laytime_end_event = fields.Selection(
('hoses_connected', 'Hoses Connected'), CHARTER_LAYTIME_EVENT_SELECTION, "Laytime End Event")
('start_pumping', 'Start Pumping'),
('end_pumping', 'End Pumping'),
('hoses_disconnected', 'Hoses Disconnected'),
('sailing', 'Sailing'),
], "Laytime End Event")
turn_time = fields.Numeric("Turn Time", digits=(16, 4)) turn_time = fields.Numeric("Turn Time", digits=(16, 4))
turn_time_unit = fields.Selection([ turn_time_unit = fields.Selection([
(None, ''), (None, ''),
@@ -618,6 +647,10 @@ class CharterCondition(ModelSQL, ModelView):
def default_all_time_saved(): def default_all_time_saved():
return False return False
@staticmethod
def default_laytime_start_rule():
return 'simple'
@staticmethod @staticmethod
def default_laytime_start_event(): def default_laytime_start_event():
return 'notice_of_readiness' return 'notice_of_readiness'
@@ -3109,6 +3142,7 @@ class StatementOfFacts(ModelSQL, ModelView):
_event_labels = { _event_labels = {
'notice_of_readiness': 'Notice of Readiness', 'notice_of_readiness': 'Notice of Readiness',
'arrival': 'Arrival', 'arrival': 'Arrival',
'all_fast': 'All Fast',
'hoses_connected': 'Hoses Connected', 'hoses_connected': 'Hoses Connected',
'start_pumping': 'Start Pumping', 'start_pumping': 'Start Pumping',
'end_pumping': 'End Pumping', 'end_pumping': 'End Pumping',
@@ -3138,6 +3172,7 @@ class StatementOfFacts(ModelSQL, ModelView):
'arrival_time': ('arrival_date', 'arrival_hour'), 'arrival_time': ('arrival_date', 'arrival_hour'),
'notice_of_readiness_time': ( 'notice_of_readiness_time': (
'notice_of_readiness_date', 'notice_of_readiness_hour'), 'notice_of_readiness_date', 'notice_of_readiness_hour'),
'all_fast': ('all_fast_date', 'all_fast_hour'),
'hoses_connected': ('hoses_connected_date', 'hoses_connected_hour'), 'hoses_connected': ('hoses_connected_date', 'hoses_connected_hour'),
'start_pumping': ('start_pumping_date', 'start_pumping_hour'), 'start_pumping': ('start_pumping_date', 'start_pumping_hour'),
'end_pumping': ('end_pumping_date', 'end_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_time = fields.DateTime('Arrival Time')
arrival_date = fields.Date('Arrival Date') arrival_date = fields.Date('Arrival Date')
arrival_hour = fields.Time('Arrival Time') 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 = fields.DateTime('Hoses Connected')
hoses_connected_date = fields.Date('Hoses Connected Date') hoses_connected_date = fields.Date('Hoses Connected Date')
hoses_connected_hour = fields.Time('Hoses Connected Time') hoses_connected_hour = fields.Time('Hoses Connected Time')
@@ -3439,10 +3477,7 @@ class StatementOfFacts(ModelSQL, ModelView):
value = getattr(condition, 'turn_time', None) value = getattr(condition, 'turn_time', None)
unit = getattr(condition, 'turn_time_unit', None) unit = getattr(condition, 'turn_time_unit', None)
if value is not None: if value is not None:
hours = Decimal(str(value)) return self._offset_delta(value, unit)
if unit == 'days':
hours *= Decimal(24)
return datetime.timedelta(hours=float(hours))
if self.laytime_clause_hour: if self.laytime_clause_hour:
t = self.laytime_clause_hour t = self.laytime_clause_hour
@@ -3453,10 +3488,20 @@ class StatementOfFacts(ModelSQL, ModelView):
return datetime.timedelta( return datetime.timedelta(
hours=t.hour, minutes=t.minute, seconds=t.second) 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): def _condition_event_datetime(self, event):
return { return {
'notice_of_readiness': self.notice_of_readiness_time, 'notice_of_readiness': self.notice_of_readiness_time,
'arrival': self.arrival_time, 'arrival': self.arrival_time,
'all_fast': self.all_fast,
'hoses_connected': self.hoses_connected, 'hoses_connected': self.hoses_connected,
'start_pumping': self.start_pumping, 'start_pumping': self.start_pumping,
'end_pumping': self.end_pumping, 'end_pumping': self.end_pumping,
@@ -3464,8 +3509,26 @@ class StatementOfFacts(ModelSQL, ModelView):
'sailing': self.sailing_time, 'sailing': self.sailing_time,
}.get(event) }.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): def _laytime_start_base(self):
condition = getattr(self, 'applied_condition', None) 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) event = getattr(condition, 'laytime_start_event', None)
base = self._condition_event_datetime(event) base = self._condition_event_datetime(event)
if base: if base:
@@ -3476,6 +3539,10 @@ class StatementOfFacts(ModelSQL, ModelView):
base = self._laytime_start_base() base = self._laytime_start_base()
if not base: if not base:
return 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() return base + self._applied_turn_delta()
def _applied_laytime_end(self): def _applied_laytime_end(self):
@@ -3517,6 +3584,9 @@ class StatementOfFacts(ModelSQL, ModelView):
def _calculation_offset_text(self): def _calculation_offset_text(self):
condition = getattr(self, 'applied_condition', None) 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) value = getattr(condition, 'laytime_start_offset', None)
unit = getattr(condition, 'laytime_start_offset_unit', None) unit = getattr(condition, 'laytime_start_offset_unit', None)
if value is None: if value is None:
@@ -3531,6 +3601,33 @@ class StatementOfFacts(ModelSQL, ModelView):
return legacy.strftime('%H:%M:%S') return legacy.strftime('%H:%M:%S')
return '' 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): def _calculation_rate_text(self, category):
rate = self._condition_rate(category) rate = self._condition_rate(category)
if rate is not None: if rate is not None:
@@ -3594,8 +3691,7 @@ class StatementOfFacts(ModelSQL, ModelView):
if name == 'calculation_despatch_rate': if name == 'calculation_despatch_rate':
return self._calculation_rate_text('despatch') return self._calculation_rate_text('despatch')
if name == 'calculation_start_event': if name == 'calculation_start_event':
return self._event_labels.get( return self._calculation_start_rule_text()
start_event, self._event_labels['notice_of_readiness'])
if name == 'calculation_start_event_time': if name == 'calculation_start_event_time':
return self._format_calculation_datetime( return self._format_calculation_datetime(
self._laytime_start_base()) 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="type">form</field>
<field name="name">charter_condition_rate_form</field> <field name="name">charter_condition_rate_form</field>
</record> </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"> <record model="ir.ui.view" id="charter_condition_view_tree">
<field name="model">charter.condition</field> <field name="model">charter.condition</field>
<field name="type">tree</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_create" eval="True"/>
<field name="perm_delete" eval="True"/> <field name="perm_delete" eval="True"/>
</record> </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"> <record model="ir.model.access" id="access_charter_party_group">
<field name="model">stock.charter.party</field> <field name="model">stock.charter.party</field>
<field name="group" ref="purchase.group_purchase"/> <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_create" eval="True"/>
<field name="perm_delete" eval="True"/> <field name="perm_delete" eval="True"/>
</record> </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"> <record model="ir.action.wizard" id="act_vf">
<field name="name">🚢 Find vessel</field> <field name="name">🚢 Find vessel</field>

View File

@@ -1502,6 +1502,38 @@ class PurchaseTradeTestCase(ModuleTestCase):
sof._applied_laytime_start(), sof._applied_laytime_start(),
datetime.datetime(2026, 6, 1, 8, 0)) 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() @with_transaction()
def test_sof_laytime_end_reads_structured_condition_rule(self): def test_sof_laytime_end_reads_structured_condition_rule(self):
'sof laytime end reads the structured end event' 'sof laytime end reads the structured end event'
@@ -1593,11 +1625,16 @@ class PurchaseTradeTestCase(ModuleTestCase):
values = Sof._sync_datetime_values({ values = Sof._sync_datetime_values({
'notice_of_readiness_date': datetime.date(2026, 7, 6), 'notice_of_readiness_date': datetime.date(2026, 7, 6),
'notice_of_readiness_hour': datetime.time(8, 0), '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( self.assertEqual(
values['notice_of_readiness_time'], values['notice_of_readiness_time'],
datetime.datetime(2026, 7, 6, 8, 0)) datetime.datetime(2026, 7, 6, 8, 0))
self.assertEqual(
values['all_fast'],
datetime.datetime(2026, 7, 6, 10, 30))
@with_transaction() @with_transaction()
def test_sof_event_date_time_values_sync_to_datetime(self): def test_sof_event_date_time_values_sync_to_datetime(self):

View File

@@ -20,6 +20,9 @@
<field name="all_time_saved"/> <field name="all_time_saved"/>
</page> </page>
<page string="Laytime" col="4" id="laytime"> <page string="Laytime" col="4" id="laytime">
<label name="laytime_start_rule"/>
<field name="laytime_start_rule"/>
<newline/>
<label name="laytime_start_event"/> <label name="laytime_start_event"/>
<field name="laytime_start_event"/> <field name="laytime_start_event"/>
<label name="laytime_end_event"/> <label name="laytime_end_event"/>
@@ -28,6 +31,9 @@
<field name="laytime_start_offset"/> <field name="laytime_start_offset"/>
<label name="laytime_start_offset_unit"/> <label name="laytime_start_offset_unit"/>
<field 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"/> <label name="laytime_clause"/>
<field name="laytime_clause" colspan="3"/> <field name="laytime_clause" colspan="3"/>
<label name="laytime_start"/> <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"/>
<field name="party_role"/> <field name="party_role"/>
<field name="responsibility"/> <field name="responsibility"/>
<field name="laytime_start_rule"/>
<field name="laytime_start_event"/> <field name="laytime_start_event"/>
<field name="laytime_start_offset"/> <field name="laytime_start_offset"/>
<field name="laytime_start_offset_unit"/> <field name="laytime_start_offset_unit"/>

View File

@@ -28,6 +28,11 @@
<label name="notice_of_readiness_hour"/> <label name="notice_of_readiness_hour"/>
<field name="notice_of_readiness_hour"/> <field name="notice_of_readiness_hour"/>
<newline/> <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"/> <label name="hoses_connected_date"/>
<field name="hoses_connected_date"/> <field name="hoses_connected_date"/>
<label name="hoses_connected_hour"/> <label name="hoses_connected_hour"/>