diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index 5ead2a6..1e31e45 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -1953,9 +1953,25 @@ import io import re import PyPDF2 -class StatementOfFacts(ModelSQL, ModelView): - "Statement of Facts for Vessel Discharge" - __name__ = 'sof.statement' +class StatementOfFacts(ModelSQL, ModelView): + "Statement of Facts for Vessel Discharge" + __name__ = 'sof.statement' + + _datetime_parts = { + 'arrival_time': ('arrival_date', 'arrival_hour'), + 'notice_of_readiness_time': ( + 'notice_of_readiness_date', 'notice_of_readiness_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'), + 'hoses_disconnected': ( + 'hoses_disconnected_date', 'hoses_disconnected_hour'), + 'sailing_time': ('sailing_date', 'sailing_hour'), + 'laytime_commenced': ( + 'laytime_commenced_date', 'laytime_commenced_hour'), + 'laytime_completed': ( + 'laytime_completed_date', 'laytime_completed_hour'), + } # === A. Informations générales === shipment = fields.Many2One('stock.shipment.in', 'Shipment', required=True, ondelete='CASCADE') @@ -2008,19 +2024,41 @@ class StatementOfFacts(ModelSQL, ModelView): sof = fields.Many2One('document.incoming',"Statement of facts") # === B. Événements du SOF === - sof_events = fields.One2Many('sof.event', 'statement', "SOF Events") - - arrival_time = fields.DateTime('Arrival Time') - hoses_connected = fields.DateTime('Hoses Connected') - start_pumping = fields.DateTime('Start Pumping') - end_pumping = fields.DateTime('End Pumping') - hoses_disconnected = fields.DateTime('Hoses Disconnected') - sailing_time = fields.DateTime('Sailing Time') - - # === C. Résultats du calcul === - laytime_commenced = fields.DateTime('Laytime Commenced') - laytime_completed = fields.DateTime('Laytime Completed') - actual_time_used = fields.Float('Actual Time Used (hours)', readonly=True) + sof_events = fields.One2Many('sof.event', 'statement', "SOF Events") + + arrival_time = fields.DateTime('Arrival Time') + arrival_date = fields.Date('Arrival Date') + arrival_hour = fields.Time('Arrival Time') + hoses_connected = fields.DateTime('Hoses Connected') + hoses_connected_date = fields.Date('Hoses Connected Date') + hoses_connected_hour = fields.Time('Hoses Connected Time') + start_pumping = fields.DateTime('Start Pumping') + start_pumping_date = fields.Date('Start Pumping Date') + start_pumping_hour = fields.Time('Start Pumping Time') + end_pumping = fields.DateTime('End Pumping') + end_pumping_date = fields.Date('End Pumping Date') + end_pumping_hour = fields.Time('End Pumping Time') + hoses_disconnected = fields.DateTime('Hoses Disconnected') + hoses_disconnected_date = fields.Date('Hoses Disconnected Date') + hoses_disconnected_hour = fields.Time('Hoses Disconnected Time') + sailing_time = fields.DateTime('Sailing Time') + sailing_date = fields.Date('Sailing Date') + sailing_hour = fields.Time('Sailing Time') + notice_of_readiness_date = fields.Date('Notice of Readiness Date') + notice_of_readiness_hour = fields.Time('Notice of Readiness Time') + + # === C. Résultats du calcul === + laytime_commenced = fields.DateTime('Laytime Commenced') + laytime_commenced_date = fields.Date( + 'Laytime Commenced Date', readonly=True) + laytime_commenced_hour = fields.Time( + 'Laytime Commenced Time', readonly=True) + laytime_completed = fields.DateTime('Laytime Completed') + laytime_completed_date = fields.Date( + 'Laytime Completed Date', readonly=True) + laytime_completed_hour = fields.Time( + 'Laytime Completed Time', readonly=True) + actual_time_used = fields.Float('Actual Time Used (hours)', readonly=True) deductions = fields.Float('Deductions (hours)', readonly=True) laytime_used = fields.Float('Laytime Used (hours)', readonly=True) laytime_balance = fields.Float('Laytime Balance (hours)', readonly=True) @@ -2034,6 +2072,76 @@ class StatementOfFacts(ModelSQL, ModelView): notes = fields.Text('Remarks / Notes') + @staticmethod + def _combine_date_time(date, time): + if date and time: + return datetime.datetime.combine(date, time) + + @staticmethod + def _split_datetime(value): + if value: + return value.date(), value.time() + return None, None + + @classmethod + def _sync_datetime_values(cls, values): + for datetime_field, parts in cls._datetime_parts.items(): + date_field, time_field = parts + if date_field in values or time_field in values: + values[datetime_field] = cls._combine_date_time( + values.get(date_field), values.get(time_field)) + return values + + @classmethod + def _sync_datetime_write_values(cls, record, values): + values = dict(values) + for datetime_field, parts in cls._datetime_parts.items(): + date_field, time_field = parts + if date_field in values or time_field in values: + values[datetime_field] = cls._combine_date_time( + values.get(date_field, getattr(record, date_field, None)), + values.get(time_field, getattr(record, time_field, None))) + return values + + @classmethod + def _sync_datetime_parts(cls, statements): + for statement in statements: + changed = False + for datetime_field, parts in cls._datetime_parts.items(): + value = getattr(statement, datetime_field, None) + date_field, time_field = parts + date_value, time_value = cls._split_datetime(value) + if getattr(statement, date_field, None) != date_value: + setattr(statement, date_field, date_value) + changed = True + if getattr(statement, time_field, None) != time_value: + setattr(statement, time_field, time_value) + changed = True + if changed: + statement.save() + + @classmethod + def create(cls, vlist): + vlist = [cls._sync_datetime_values(dict(values)) for values in vlist] + statements = super().create(vlist) + cls._sync_datetime_parts(statements) + return statements + + @classmethod + def write(cls, *args): + new_args = [] + written_records = [] + for index in range(0, len(args), 2): + records = args[index] + values = args[index + 1] + for record in records: + new_args.extend([ + [record], + cls._sync_datetime_write_values(record, values)]) + written_records.extend(records) + super().write(*new_args) + cls._sync_datetime_parts(written_records) + @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: @@ -2148,7 +2256,7 @@ class StatementOfFacts(ModelSQL, ModelView): if self.shipment: return self.shipment.get_quantity() -class SoFEvent(ModelSQL, ModelView): +class SoFEvent(ModelSQL, ModelView): "Event from Statement of Facts" __name__ = 'sof.event' @@ -2163,8 +2271,53 @@ class SoFEvent(ModelSQL, ModelView): end_time = fields.Time("End Time (H)") duration = fields.Float("Duration") - description = fields.Char("Description") - deductible = fields.Boolean("Deduct from Laytime") + description = fields.Char("Description") + deductible = fields.Boolean("Deduct from Laytime") + + @staticmethod + def _combine_date_time(date, time): + if date and time: + return datetime.datetime.combine(date, time) + + @classmethod + def _sync_datetime_values(cls, values): + if 'start_date' in values or 'start_time' in values: + values['start'] = cls._combine_date_time( + values.get('start_date'), values.get('start_time')) + if 'end_date' in values or 'end_time' in values: + values['end'] = cls._combine_date_time( + values.get('end_date'), values.get('end_time')) + return values + + @classmethod + def _sync_datetime_write_values(cls, record, values): + values = dict(values) + if 'start_date' in values or 'start_time' in values: + values['start'] = cls._combine_date_time( + values.get('start_date', getattr(record, 'start_date', None)), + values.get('start_time', getattr(record, 'start_time', None))) + if 'end_date' in values or 'end_time' in values: + values['end'] = cls._combine_date_time( + values.get('end_date', getattr(record, 'end_date', None)), + values.get('end_time', getattr(record, 'end_time', None))) + return values + + @classmethod + def create(cls, vlist): + return super().create([ + cls._sync_datetime_values(dict(values)) for values in vlist]) + + @classmethod + def write(cls, *args): + new_args = [] + for index in range(0, len(args), 2): + records = args[index] + values = args[index + 1] + for record in records: + new_args.extend([ + [record], + cls._sync_datetime_write_values(record, values)]) + super().write(*new_args) class ImportSoFStart(ModelView): """Wizard Start View""" diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index b8332d6..45f57f2 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -214,6 +214,35 @@ class PurchaseTradeTestCase(ModuleTestCase): sof._applied_compensation_amount(), Decimal('-12000.00')) self.assertEqual(sof.compensation_type, 'demurrage') + @with_transaction() + def test_sof_header_date_time_values_sync_to_datetime(self): + 'sof header date and time fields build the technical datetime value' + Sof = Pool().get('sof.statement') + values = Sof._sync_datetime_values({ + 'notice_of_readiness_date': datetime.date(2026, 7, 6), + 'notice_of_readiness_hour': datetime.time(8, 0), + }) + + self.assertEqual( + values['notice_of_readiness_time'], + datetime.datetime(2026, 7, 6, 8, 0)) + + @with_transaction() + def test_sof_event_date_time_values_sync_to_datetime(self): + 'sof event date and time fields build the technical datetime values' + Event = Pool().get('sof.event') + values = Event._sync_datetime_values({ + 'start_date': datetime.date(2026, 1, 1), + 'start_time': datetime.time(10, 0), + 'end_date': datetime.date(2026, 1, 1), + 'end_time': datetime.time(12, 0), + }) + + self.assertEqual( + values['start'], datetime.datetime(2026, 1, 1, 10, 0)) + self.assertEqual( + values['end'], datetime.datetime(2026, 1, 1, 12, 0)) + def test_contract_clause_variables_and_rendering_use_contract_context(self): 'contract clauses expose placeholders and render from purchase context' clause = purchase_module.ContractClause() diff --git a/modules/purchase_trade/view/sof_event_form.xml b/modules/purchase_trade/view/sof_event_form.xml index 9f31aa2..a289107 100755 --- a/modules/purchase_trade/view/sof_event_form.xml +++ b/modules/purchase_trade/view/sof_event_form.xml @@ -1,8 +1,10 @@