From d483e7cf69b56875d088a0152b7189d46ff641c3 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sun, 31 May 2026 22:58:42 +0200 Subject: [PATCH] Charter party --- modules/purchase_trade/stock.py | 132 ++++++++++++++++---- modules/purchase_trade/tests/test_module.py | 24 ++++ modules/purchase_trade/view/sof_form.xml | 13 +- 3 files changed, 135 insertions(+), 34 deletions(-) diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index a64b9c8..a34b63a 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -1576,28 +1576,30 @@ class ShipmentIn(metaclass=PoolMeta): @classmethod @ModelView.button def compute(cls, shipments): - Sof = Pool().get('sof.statement') - for sh in shipments: - if sh.sof: - for s in sh.sof: - if s.laytime_clause_hour: - t = s.laytime_clause_hour # instance de datetime.time - delta = datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second) - s.laytime_commenced = s.notice_of_readiness_time + delta + Sof = Pool().get('sof.statement') + for sh in shipments: + if sh.sof: + for s in sh.sof: + if s.notice_of_readiness_time: + s.laytime_commenced = ( + s.notice_of_readiness_time + + s._applied_turn_delta()) quantity = float(s.quantity or sh.get_quantity() or 0) - s.laytime_allowed = round(quantity / s.pumping_rate_o,2) - s.laytime_completed = s.hoses_disconnected - #s.laytime_completed = s.laytime_commenced + datetime.timedelta(hours=s.laytime_allowed) - total_time = (s.laytime_completed - s.laytime_commenced).total_seconds() / 3600.0 - logger.info("COMPUTE_DEDUCTION3:%s",total_time) - s.actual_time_used = round(total_time,2) - s.deductions = round(sum([e.duration for e in s.sof_events if e.deductible == True]),2) - effective_time = round(total_time - s.deductions,2) - s.laytime_used = effective_time - s.laytime_balance = round(s.laytime_allowed - s.laytime_used,2) - penalty = Decimal(s.laytime_balance) * (s.demurrage_rate_o / 24) - s.compensation_amount = round(penalty, 2) - Sof.save([s]) + s.laytime_allowed = s._applied_laytime_allowed(quantity) + s.laytime_completed = s.hoses_disconnected + #s.laytime_completed = s.laytime_commenced + datetime.timedelta(hours=s.laytime_allowed) + if not s.laytime_completed or s.laytime_allowed is None: + Sof.save([s]) + continue + total_time = (s.laytime_completed - s.laytime_commenced).total_seconds() / 3600.0 + logger.info("COMPUTE_DEDUCTION3:%s",total_time) + s.actual_time_used = round(total_time,2) + s.deductions = round(sum([e.duration for e in s.sof_events if e.deductible == True]),2) + effective_time = round(total_time - s.deductions,2) + s.laytime_used = effective_time + s.laytime_balance = round(s.laytime_allowed - s.laytime_used,2) + s.compensation_amount = s._applied_compensation_amount() + Sof.save([s]) def get_vessel_url(self): return f"https://www.vesselfinder.com/en/vessels/VOS-TRAVELLER-IMO-{self.vessel.vessel_imo}" @@ -1944,6 +1946,8 @@ class StatementOfFacts(ModelSQL, ModelView): timebar_day = fields.Integer("TimeBar") timebar_warn = fields.Integer("Warning TimeBar") charter_party = fields.Many2One('stock.charter.party', "Charter Party") + applied_condition = fields.Many2One( + 'charter.condition', "Applied Condition") owner_charter_conditions = fields.Function( fields.One2Many('charter.condition', '', "Owner Conditions"), 'get_owner_charter_conditions') @@ -1978,15 +1982,27 @@ class StatementOfFacts(ModelSQL, ModelView): ('despatch', 'Despatch'), (None, 'No Compensation'), ], 'Compensation Type') - compensation_amount = fields.Numeric('Compensation Amount ($)', readonly=True, digits=(16, 2)) + compensation_amount = fields.Numeric('Compensation Amount ($)', readonly=True, digits=(16, 2)) quantity = fields.Function(fields.Float("Quantity loaded"),'get_qt') notes = fields.Text('Remarks / Notes') - @fields.depends('shipment', 'charter_party') + @fields.depends('shipment', 'charter_party', 'applied_condition') def on_change_shipment(self): if self.shipment and self.shipment.charter_party and not self.charter_party: self.charter_party = self.shipment.charter_party + self._set_default_applied_condition() + + @fields.depends('charter_party', 'applied_condition') + def on_change_charter_party(self): + self._set_default_applied_condition() + + def _set_default_applied_condition(self): + if self.applied_condition: + return + conditions = getattr(self.charter_party, 'conditions', None) or [] + if len(conditions) == 1: + self.applied_condition = conditions[0] def get_owner_charter_conditions(self, name=None): if self.charter_party: @@ -2005,6 +2021,76 @@ class StatementOfFacts(ModelSQL, ModelView): return self.shipment.get_sale_charter_conditions(name) return [] + @staticmethod + def _rate_category(rate): + rate_type = getattr(rate, 'rate_type', None) + category = getattr(rate_type, 'category', None) + if category: + return category + label = ' '.join(filter(None, [ + getattr(rate_type, 'name', None), + getattr(rate, 'description', None), + ])).lower() + for category in ('demurrage', 'despatch', 'pumping'): + if category in label: + return category + + def _condition_rate(self, category): + condition = getattr(self, 'applied_condition', None) + for rate in getattr(condition, 'rates', None) or []: + if self._rate_category(rate) == category and rate.rate is not None: + return Decimal(str(rate.rate)) + + def _applied_laytime_allowed(self, quantity): + condition = getattr(self, 'applied_condition', None) + if condition and condition.laytime_allowed is not None: + allowed = Decimal(str(condition.laytime_allowed)) + if condition.laytime_unit in { + 'days', 'wwd', 'wwd_shex', 'wwd_shinc'}: + allowed *= Decimal(24) + return float(allowed) + + pumping_rate = self._condition_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 and condition.turn_time is not None: + hours = Decimal(str(condition.turn_time)) + if condition.turn_time_unit == 'days': + hours *= Decimal(24) + return datetime.timedelta(hours=float(hours)) + + if self.laytime_clause_hour: + t = self.laytime_clause_hour + elif self.laytime_clause_hour_o: + t = self.laytime_clause_hour_o + else: + return datetime.timedelta(0) + return datetime.timedelta( + hours=t.hour, minutes=t.minute, seconds=t.second) + + def _applied_compensation_amount(self): + 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)) + return round(balance * rate / Decimal(24), 2) + if balance > 0: + rate = self._condition_rate('despatch') + if rate is not None: + self.compensation_type = 'despatch' + return round(balance * rate / Decimal(24), 2) + self.compensation_type = None + return Decimal(0) + 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 7d70239..a8a31c3 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -150,6 +150,30 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(rate.basis, 'per_day') + @with_transaction() + def test_sof_calculation_reads_applied_condition_rates(self): + 'sof demurrage calculation uses the selected charter condition rates' + Sof = Pool().get('sof.statement') + sof = Sof() + sof.applied_condition = Mock( + laytime_allowed=None, + turn_time=Decimal('6'), + turn_time_unit='hours', + rates=[ + Mock(rate=Decimal('300'), rate_type=Mock(category='pumping')), + Mock(rate=Decimal('24000'), rate_type=Mock(category='demurrage')), + ], + ) + sof.laytime_balance = -Decimal('12') + + self.assertEqual( + sof._applied_turn_delta(), datetime.timedelta(hours=6)) + self.assertEqual( + sof._applied_laytime_allowed(Decimal('600')), 2.0) + self.assertEqual( + sof._applied_compensation_amount(), Decimal('-12000.00')) + self.assertEqual(sof.compensation_type, 'demurrage') + @with_transaction() def test_add_physical_lot_defaults_hidden_premium_and_chunk_key(self): 'add physical lot works when hidden tree fields are not loaded' diff --git a/modules/purchase_trade/view/sof_form.xml b/modules/purchase_trade/view/sof_form.xml index 1dfa781..8696a5c 100755 --- a/modules/purchase_trade/view/sof_form.xml +++ b/modules/purchase_trade/view/sof_form.xml @@ -4,17 +4,8 @@