# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.model import fields, Workflow
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, Id
from trytond.model import (ModelSQL, ModelView)
from trytond.tools import is_full_text, lstrip_wildcard
from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_HALF_UP
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case
from sql import Column, Literal
from sql.functions import CurrentTimestamp, DateTrunc
from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction
from itertools import chain, groupby
from operator import itemgetter
import datetime
from collections import defaultdict
from sql import Table
from trytond.modules.purchase_trade.service import ContractFactory
import requests
import io
import base64
import logging
import json
import re
import html
from trytond.exceptions import UserError
from trytond.modules.stock.shipment import SupplierShipping as BaseSupplierShipping


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'),
    ('completed_loading', 'Completed Loading'),
    ('completed_discharge', 'Completed Discharge'),
    ('hoses_disconnected', 'Hoses Disconnected'),
    ('documents_on_board', 'Documents on Board'),
    ('sailing', 'Sailing'),
]

class Location(metaclass=PoolMeta):
    __name__ = 'stock.location'

    def get_places(self):
        t = Table('places')
        cursor = Transaction().connection.cursor()
        cursor.execute(*t.select(
            t.PLACE_KEY,
            where=t.PLACE_NAME.ilike(f'%{self.name}%')
        ))
        rows = cursor.fetchall()
        if rows:
            return int(rows[0][0])
    
    @classmethod
    def getLocationByName(cls, location, type):
        location = location.upper()
        loc = cls.search([('name', '=', location),('type', '=', type)], limit=1)
        if loc:
            return loc[0].id
        else:
            loc = cls()
            loc.name = location
            loc.type = type
            cls.save([loc])
            return loc
        
    @classmethod
    def get_transit_id(cls):
        return cls.getLocationByName('TRANSIT','storage')
        
    def is_transit(self):
        if self.name == 'Transit':
            return True

class Move(metaclass=PoolMeta):
    __name__ = 'stock.move'

    bldate = fields.Date("BL date")
    blnumber = fields.Char("BL number")
    lotqt = fields.One2Many('lot.qt','lot_move',"Lots")
    lot = fields.Many2One('lot.lot',"Lot")
    trade_purchase = fields.Function(
        fields.Many2One('purchase.purchase', "Purchase"), 'get_trade_purchase')
    trade_sale = fields.Function(
        fields.Many2One('sale.sale', "Sale"), 'get_trade_sale')
    trade_purchase_reference = fields.Function(
        fields.Char("Purchase Ref."), 'get_trade_purchase_reference')
    trade_sale_reference = fields.Function(
        fields.Char("Sale Ref."), 'get_trade_sale_reference')

    def _trade_purchase_line(self):
        return getattr(getattr(self, 'lot', None), 'line', None)

    def _trade_sale_line(self):
        return getattr(getattr(self, 'lot', None), 'sale_line', None)

    @staticmethod
    def _contract_reference(contract):
        if not contract:
            return None
        return (
            getattr(contract, 'reference', None)
            or getattr(contract, 'our_reference', None))

    def get_trade_purchase(self, name):
        line = self._trade_purchase_line()
        purchase = getattr(line, 'purchase', None)
        return purchase.id if purchase else None

    def get_trade_sale(self, name):
        line = self._trade_sale_line()
        sale = getattr(line, 'sale', None)
        return sale.id if sale else None

    def get_trade_purchase_reference(self, name):
        line = self._trade_purchase_line()
        return self._contract_reference(getattr(line, 'purchase', None))

    def get_trade_sale_reference(self, name):
        line = self._trade_sale_line()
        return self._contract_reference(getattr(line, 'sale', None))

    def get_linked_transit_move(self):
        if self.from_location.is_transit():
            Move = Pool().get('stock.move')
            Location = Pool().get('stock.location')
            moves = Move.search([('lot','=',self.lot),('to_location','=',Location.get_transit_id())],order=[('id', 'DESC')],limit=1)
            return moves[0] if moves else None

    @classmethod
    def validate(cls, moves):
        super(Move, cls).validate(moves)
        Lot = Pool().get('lot.lot')
        LotMove = Pool().get('lot.move')
        for move in moves:
            if move.lot:
                l = Lot(move.lot.id)
                lm = LotMove.search([('lot','=',l.id),('move','=',move)])
                if len(lm) == 0:
                    lm = LotMove.search(['lot','>',0],order=[('sequence','DESC')])
                    new_lm = LotMove()
                    new_lm.move = move.id
                    new_lm.lot = l.id
                    new_lm.sequence = 1
                    if lm:
                        new_lm.sequence = lm[0].sequence + 1
                    
                    LotMove.save([new_lm])

class InvoiceLine(metaclass=PoolMeta):
    __name__ = 'account.invoice.line'

    lot = fields.Many2One('lot.lot',"Lot")
    fee = fields.Many2One('fee.fee',"Fee")
    included_padding = fields.Function(
        fields.Numeric("Inc. padding", digits=(1, 5)),
        'get_included_padding')

    def get_included_padding(self, name):
        if (self.lot
                and self.invoice_type == 'out'
                and self.description == 'Pro forma'
                and Decimal(str(self.quantity or 0)) > 0):
            return self.lot.sale_invoice_padding or Decimal(0)
        return Decimal(0)

    @classmethod
    def validate(cls, lines):
        super(InvoiceLine, cls).validate(lines)
        Lot = Pool().get('lot.lot')
        for line in lines:
            if line.lot and line.quantity > 0:
                l = Lot(line.lot.id)
                if line.description == 'Pro forma':
                    if line.invoice_type == 'in':
                        l.invoice_line_prov = line.id
                        l.lot_pur_inv_state = 'prov'
                    else:
                        l.sale_invoice_line_prov = line.id
                        l.lot_sale_inv_state = 'prov'
                elif line.description == 'Final':
                    if line.invoice_type == 'in':
                        l.invoice_line = line.id
                        l.lot_pur_inv_state = 'invoiced'
                    else:
                        l.sale_invoice_line = line.id
                        l.lot_sale_inv_state = 'invoiced'
                Lot.save([l])

class ShipmentInternal(metaclass=PoolMeta):
    __name__ = 'stock.shipment.internal'
    fees = fields.One2Many('fee.fee','shipment_internal',"Fees")

sof_json = [
            {
                "date": "2025-02-02",
                "time": "19:00",
                "event": "S/B FOR ARRIVAL - PROCEED TO CAK NO.2 ANCHORAGE",
                "end_time": "2025-02-02 21:30",
                "duration": {"iso_8601": "PT26H30M", "text": "2h 30m"},
            },
            {
                "date": "2025-02-02",
                "time": "21:30",
                "event": "DROP ANCHOR AT CAK NO.2 ANCHOR / ARR. CAN NOR TENDERED",
                "end_time": "2025-02-02 21:40",
                "duration": {"iso_8601": "-PT23H50M", "text": "10m"},
            },
            {
                "date": "2025-02-02",
                "time": "21:40",
                "event": "BROUGHT UP ANCHOR/F.W.E",
                "end_time": "2025-02-03 07:20",
                "duration": {"iso_8601": "PT9H40M", "text": "9h 40m"},
            },
            {
                "date": "2025-02-03",
                "time": "07:20",
                "event": "S.B.E. FOR SHIFTING",
                "end_time": "2025-02-03 07:45",
                "duration": {"iso_8601": "PT0H25M", "text": "25m"},
            },
            {
                "date": "2025-02-03",
                "time": "07:45",
                "event": "ANCHOR AWEIGHT",
                "end_time": "2025-02-03 10:45",
                "duration": {"iso_8601": "PT3H0M", "text": "3h"},
            },
            {
                "date": "2025-02-03",
                "time": "10:45",
                "event": "SEA PILOT ON BOARD",
                "end_time": "2025-02-03 14:00",
                "duration": {"iso_8601": "PT3H15M", "text": "3h 15m"},
            },
            {
                "date": "2025-02-03",
                "time": "14:00",
                "event": "EXCHANGED SEA PILOT TO RIVER PILOT",
                "end_time": "2025-02-03 17:45",
                "duration": {"iso_8601": "PT3H45M", "text": "3h 45m"},
            },
            {
                "date": "2025-02-03",
                "time": "17:45",
                "event": "DROP ANCHOR AT NANTONS DANGEROUS ANCHORAGE",
                "end_time": "2025-02-03 18:00",
                "duration": {"iso_8601": "PT0H15M", "text": "15m"},
            },
            {
                "date": "2025-02-03",
                "time": "18:00",
                "event": "BROUGHT UP ANCHOR/F.W.E/PILOT OFF",
                "end_time": "2025-02-04 06:30",
                "duration": {"iso_8601": "PT12H30M", "text": "12h 30m"},
            },
            {
                "date": "2025-02-04",
                "time": "06:30",
                "event": "S.B.E. FOR BERTHING",
                "end_time": "2025-02-04 07:10",
                "duration": {"iso_8601": "PT0H40M", "text": "40m"},
            },
            {
                "date": "2025-02-04",
                "time": "07:10",
                "event": "PILOT ON BOARD",
                "end_time": "2025-02-04 07:15",
                "duration": {"iso_8601": "PT0H5M", "text": "5m"},
            },
            {
                "date": "2025-02-04",
                "time": "07:15",
                "event": "ANCHOR AWEIGHT",
                "end_time": "2025-02-04 09:20",
                "duration": {"iso_8601": "PT2H5M", "text": "2h 5m"},
            },
            {
                "date": "2025-02-04",
                "time": "09:20",
                "event": "FWD TUG MADE FAST",
                "end_time": "2025-02-04 09:35",
                "duration": {"iso_8601": "PT0H15M", "text": "15m"},
            },
            {
                "date": "2025-02-04",
                "time": "09:35",
                "event": "FIRST LINE ASHORE",
                "end_time": "2025-02-04 08:50",
                "duration": {
                    "iso_8601": "-PT0H45M",
                    "text": "Incohérence temporelle",
                    "alert": "Vérifier l'heure de TUG OFF",
                },
            },
            {
                "date": "2025-02-04",
                "time": "08:50",
                "event": "TUG OFF",
                "end_time": "2025-02-04 09:55",
                "duration": {"iso_8601": "PT1H5M", "text": "1h 5m"},
            },
            {
                "date": "2025-02-04",
                "time": "09:55",
                "event": "ALL LINE MADE FAST/F.W.E / NOR ACCEPTED",
                "end_time": "2025-02-04 10:15",
                "duration": {"iso_8601": "PT0H20M", "text": "20m"},
            },
            {
                "date": "2025-02-04",
                "time": "10:15",
                "event": "GANGWAY DOWN/PILOT OFF",
                "end_time": "2025-02-04 10:20",
                "duration": {"iso_8601": "PT0H5M", "text": "5m"},
            },
            {
                "date": "2025-02-04",
                "time": "10:20",
                "event": "AGENT / BAMBRIATION ON BOARD",
                "end_time": "2025-02-04 11:10",
                "duration": {"iso_8601": "PT0H50M", "text": "50m"},
            },
            {
                "date": "2025-02-04",
                "time": "11:10",
                "event": "FREE-PRACTIQUE QUANTED",
                "end_time": "2025-02-04 11:40",
                "duration": {"iso_8601": "PT0H30M", "text": "30m"},
            },
            {
                "date": "2025-02-04",
                "time": "11:40",
                "event": "SURVEYOR & LOADING MASTER ON BOARD",
                "end_time": "2025-02-04 11:40",
                "duration": {
                    "iso_8601": "PT0H0M",
                    "text": "0m",
                    "note": "Début de SAFETY MEETING",
                },
            },
            {
                "date": "2025-02-04",
                "time": "11:40",
                "event": "SAFETY MEETING",
                "end_time": "2025-02-04 12:20",
                "duration": {"iso_8601": "PT0H40M", "text": "40m"},
            },
            {
                "date": "2025-02-04",
                "time": "11:45",
                "event": "TANK INSPECTION",
                "end_time": "2025-02-04 12:30",
                "duration": {"iso_8601": "PT0H45M", "text": "45m"},
            },
            {
                "date": "2025-02-04",
                "time": "13:10",
                "event": 'CARGO HOSE CONNECTED (3 x 8")',
                "end_time": "2025-02-04 13:25",
                "duration": {"iso_8601": "PT0H15M", "text": "15m"},
            },
            {
                "date": "2025-02-04",
                "time": "13:25",
                "event": "LEAKAGE TESTED",
                "end_time": "2025-02-04 13:45",
                "duration": {"iso_8601": "PT0H20M", "text": "20m"},
            },
            {
                "date": "2025-02-04",
                "time": "14:15",
                "event": "COMMENCED LOADING SULPHURIC ACID",
                "end_time": "2025-02-05 08:50",
                "duration": {"iso_8601": "PT18H35M", "text": "18h 35m"},
            },
            {
                "date": "2025-02-05",
                "time": "08:50",
                "event": "COMPLETED LOADING SULPHURIC ACID",
                "end_time": "2025-02-05 09:00",
                "duration": {"iso_8601": "PT0H10M", "text": "10m"},
            },
            {
                "date": "2025-02-05",
                "time": "08:50",
                "event": "AIR BLOWING",
                "end_time": "2025-02-05 09:00",
                "duration": {"iso_8601": "PT0H10M", "text": "10m"},
            },
            {
                "date": "2025-02-05",
                "time": "09:00",
                "event": "VILLAGING, SAMPLING AND CALCULATION",
                "end_time": "2025-02-05 11:00",
                "duration": {"iso_8601": "PT2H0M", "text": "2h"},
            },
            {
                "date": "2025-02-05",
                "time": "10:40",
                "event": 'HOSE OFF (308")',
                "end_time": "2025-02-05 11:40",
                "duration": {"iso_8601": "PT1H0M", "text": "1h"},
            },
            {
                "date": "2025-02-05",
                "time": "11:40",
                "event": "CARGO DOCUMENT COMPLETED",
                "end_time": "2025-02-05 11:40",
                "duration": {"iso_8601": "PT0H0M", "text": "Événement final"},
            },
        ]

class ContainerType(ModelSQL, ModelView):
    "Container Type"
    __name__ = 'stock.container.type'

    code = fields.Char('Code', required=True)
    name = fields.Char('Description', required=True)

    teu = fields.Numeric('TEU Factor', digits=(5, 2), required=True)

    is_reefer = fields.Boolean('Reefer')
    is_special = fields.Boolean('Special Equipment')

class ShipmentContainer(ModelSQL, ModelView):
    "Shipment Container"
    __name__ = 'stock.shipment.container'

    shipment = fields.Many2One(
        'stock.shipment.in', 'Shipment', required=True
    )

    container_type = fields.Many2One(
        'stock.container.type', 'Container Type', required=True
    )

    container_no = fields.Char('Container Number')
    quantity = fields.Integer('Quantity', required=True)
    seal_no = fields.Char('Seal Number')
    is_reefer = fields.Boolean('Reefer')

class ShipmentWR(ModelSQL,ModelView):
    "Shipment WR"
    __name__ = "shipment.wr"
    shipment_in = fields.Many2One('stock.shipment.in',"Shipment In")
    wr = fields.Many2One('weight.report',"WR")


RATE_BASIS = [
    (None, ''),
    ('per_day', 'Per Day'),
    ('per_hour', 'Per Hour'),
    ('mt_per_hour', 'MT/hour'),
    ('per_mt', 'Per MT'),
    ('per_wmt', 'Per WMT'),
    ('per_dmt', 'Per DMT'),
    ('per_cbm', 'Per CBM'),
    ('per_lot', 'Per Lot'),
    ('lumpsum', 'Lumpsum'),
    ('percent', 'Percent'),
    ('other', 'Other'),
]


class CharterRateType(ModelSQL, ModelView):
    "Charter Rate Type"
    __name__ = 'charter.rate.type'

    name = fields.Char("Name", required=True)
    code = fields.Char("Code")
    category = fields.Selection([
        (None, ''),
        ('demurrage', 'Demurrage'),
        ('despatch', 'Despatch'),
        ('pumping', 'Pumping'),
        ('freight', 'Freight'),
        ('detention', 'Detention'),
        ('waiting', 'Waiting'),
        ('shifting', 'Shifting'),
        ('heating', 'Heating'),
        ('cleaning', 'Cleaning'),
        ('bunker', 'Bunker'),
        ('port_cost', 'Port Cost'),
        ('other', 'Other'),
    ], "Category")
    default_basis = fields.Selection(RATE_BASIS, "Default Basis")
    active = fields.Boolean("Active")

    @staticmethod
    def default_active():
        return True


class CharterConditionRate(ModelSQL, ModelView):
    "Charter Condition Rate"
    __name__ = 'charter.condition.rate'

    condition = fields.Many2One(
        'charter.condition', "Condition", required=True, ondelete='CASCADE')
    rate_type = fields.Many2One('charter.rate.type', "Type")
    description = fields.Char("Description")
    rate = fields.Numeric("Rate", digits=(16, 6))
    currency = fields.Many2One('currency.currency', "Curr.")
    basis = fields.Selection(RATE_BASIS, "Basis")
    applies_to = fields.Selection([
        (None, ''),
        ('load', 'Load'),
        ('discharge', 'Discharge'),
        ('both', 'Both'),
        ('waiting', 'Waiting'),
        ('pumping', 'Pumping'),
        ('shifting', 'Shifting'),
        ('voyage', 'Voyage'),
        ('other', 'Other'),
    ], "Applies To")
    minimum = fields.Numeric("Min", digits=(16, 6))
    maximum = fields.Numeric("Max", digits=(16, 6))
    reference = fields.Text("Reference")

    @fields.depends('rate_type')
    def on_change_rate_type(self):
        if self.rate_type and 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):
    "Charter Condition"
    __name__ = 'charter.condition'

    _party_role_selection = [
        (None, ''),
        ('owner', 'Owner'),
        ('charterer', 'Charterer'),
        ('supplier', 'Supplier'),
        ('customer', 'Customer'),
        ('broker', 'Broker'),
        ('agent', 'Agent'),
        ('terminal', 'Terminal'),
        ('other', 'Other'),
    ]

    name = fields.Char("Name")
    charter_party = fields.Many2One(
        'stock.charter.party', "Charter Party", ondelete='CASCADE')
    purchase = fields.Many2One(
        'purchase.purchase', "Purchase", ondelete='CASCADE')
    purchase_line = fields.Many2One(
        'purchase.line', "Purchase Line", ondelete='CASCADE')
    party = fields.Many2One('party.party', "Party")
    party_role = fields.Selection(
        _party_role_selection, "Party Role")
    responsibility = fields.Selection([
        (None, ''),
        ('ours', 'Ours'),
        ('counterparty', 'Counterparty'),
        ('shared', 'Shared'),
        ('pass_through', 'Pass-through'),
        ('owner', 'Owner'),
        ('charterer', 'Charterer'),
    ], "Responsibility")
    laytime_clause = fields.Text("Laytime Clause")
    laytime_allowed = fields.Numeric("Laytime Allowed", digits=(16, 4))
    laytime_unit = fields.Selection([
        (None, ''),
        ('hours', 'Hours'),
        ('days', 'Days'),
        ('wwd', 'Weather Working Days'),
        ('wwd_shex', 'WWD SHEX'),
        ('wwd_shinc', 'WWD SHINC'),
        ('running_hours', 'Running Hours'),
    ], "Laytime Unit")
    laytime_start = fields.Text("Laytime Start")
    laytime_end = fields.Text("Laytime End")
    nor_clause = fields.Text("NOR Clause")
    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([
        (None, ''),
        ('hours', 'Hours'),
        ('days', 'Days'),
    ], "Laytime Start Offset Unit")
    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, ''),
        ('hours', 'Hours'),
        ('days', 'Days'),
    ], "Turn Time Unit")
    reversible = fields.Boolean("Reversible Laytime")
    all_time_saved = fields.Boolean("All Time Saved")
    demurrage_clause = fields.Text("Demurrage Clause")
    despatch_clause = fields.Text("Despatch Clause")
    exceptions = fields.Text("Exceptions")
    remarks = fields.Text("Remarks")
    rates = fields.One2Many(
        'charter.condition.rate', 'condition', "Rates")

    @staticmethod
    def default_reversible():
        return False

    @staticmethod
    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'

    @staticmethod
    def default_laytime_start_offset_unit():
        return 'hours'

    @staticmethod
    def default_laytime_end_event():
        return 'hoses_disconnected'

    @staticmethod
    def default_party_role():
        role_context = Transaction().context.get('charter_condition_role')
        if role_context in {'purchase', 'purchase_line', 'supplier'}:
            return 'supplier'
        if role_context in {'sale', 'sale_line', 'customer'}:
            return 'customer'
        if role_context == 'charter_party':
            return 'owner'

    @fields.depends(
        'charter_party', 'purchase', 'purchase_line', 'sale', 'sale_line')
    def get_party_roles(self):
        role_context = Transaction().context.get('charter_condition_role')
        if (role_context in {'purchase', 'purchase_line', 'supplier'}
                or getattr(self, 'purchase', None)
                or getattr(self, 'purchase_line', None)):
            return [(None, ''), ('supplier', 'Supplier')]
        if (role_context in {'sale', 'sale_line', 'customer'}
                or getattr(self, 'sale', None)
                or getattr(self, 'sale_line', None)):
            return [(None, ''), ('customer', 'Customer')]
        if role_context == 'charter_party' or getattr(
                self, 'charter_party', None):
            return [
                item for item in self._party_role_selection
                if item[0] not in {'supplier', 'customer'}]
        return self._party_role_selection

    @classmethod
    def validate(cls, conditions):
        super().validate(conditions)
        for condition in conditions:
            allowed_roles = {role for role, _ in condition.get_party_roles()}
            if condition.party_role not in allowed_roles:
                raise UserError(
                    "This charter condition party role is not allowed "
                    "for its source.")


class CharterParty(ModelSQL, ModelView):
    "Charter Party"
    __name__ = 'stock.charter.party'

    name = fields.Char("Name", required=True)
    reference = fields.Char("Reference")
    active = fields.Boolean("Active")
    company = fields.Many2One('company.company', "Company")
    charter_type = fields.Selection([
        (None, ''),
        ('voyage', 'Voyage Charter'),
        ('time', 'Time Charter'),
        ('coa', 'Contract of Affreightment'),
        ('bareboat', 'Bareboat Charter'),
    ], "Charter Type", required=True)
    owner = fields.Many2One('party.party', "Owner")
    charterer = fields.Many2One('party.party', "Charterer")
    broker = fields.Many2One('party.party', "Broker")
    vessel = fields.Many2One('trade.vessel', "Vessel")
    product = fields.Many2One('product.product', "Product")
    load_port = fields.Many2One('stock.location', "Load Port")
    discharge_port = fields.Many2One('stock.location', "Discharge Port")
    from_location = fields.Many2One('stock.location', "From Location")
    to_location = fields.Many2One('stock.location', "To Location")
    laycan_from = fields.Date("Laycan From")
    laycan_to = fields.Date("Laycan To")
    contract_date = fields.Date("Contract Date")
    currency = fields.Many2One('currency.currency', "Currency")
    governing_law = fields.Char("Governing Law")
    jurisdiction = fields.Char("Jurisdiction")
    document = fields.Many2One('document.incoming', "Document")
    notes = fields.Text("Notes")
    conditions = fields.One2Many(
        'charter.condition', 'charter_party', "Conditions")

    @staticmethod
    def default_active():
        return True


class ShipmentIn(metaclass=PoolMeta):
    __name__ = 'stock.shipment.in'

    from_location = fields.Many2One(
        'stock.location', 'From location',
        domain=[('type', '!=', 'customer')])
    to_location = fields.Many2One(
        'stock.location', 'To location',
        domain=[('type', '!=', 'supplier')])
    shipment_type = fields.Function(fields.Selection([
        (None, ''),
        ('dropship', 'Dropship'),
        ('inbound', 'Inbound'),
    ], "Shipment Type"), 'get_shipment_type')
    transport_type = fields.Selection([
        ('vessel', 'Vessel'),
        ('truck', 'Truck'),
        ('other', 'Other'),
    ], 'Transport type')
    vessel = fields.Many2One('trade.vessel', "Vessel",
        states={'invisible': Eval('transport_type') == 'truck'})
    info = fields.Function(fields.Text("Info",states={'invisible': ~Eval('info',False)}),'get_info')
    anim = fields.Function(fields.Text(""),'get_anim')
    carte = fields.Function(fields.Text("",states={'invisible': ~Eval('info',False)}),'get_imo')
    fees = fields.One2Many('fee.fee','shipment_in',"Fees")
    pnl_lines = fields.Function(
        fields.One2Many('valuation.valuation.line', '', "Pnl"),
        'get_pnl_lines')
    lotqt = fields.One2Many('lot.qt','lot_shipment_in',"Lots")
    quantity = fields.Function(fields.Numeric("Quantity"),'get_quantity')
    unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit')
    list_from_locations = fields.Function(
        fields.Char("From"), 'get_list_from_locations')
    list_to_locations = fields.Function(
        fields.Char("To"), 'get_list_to_locations')
    list_purchase_suppliers = fields.Function(
        fields.Char("Supplier"), 'get_list_purchase_suppliers')
    bl_date = fields.Date("BL date")
    bl_number = fields.Char("BL number")
    etl = fields.Date("Est. Loading")
    eta = fields.Date("🏗️ETA POL")
    etad = fields.Date("⚓ETA")
    atad = fields.Date("Act. Destination")
    etd = fields.Date("ETD")
    unloaded = fields.Date("Act. Discharge")
    booking = fields.Char("Booking Nb")
    booking_date =fields.Date("Booking date")
    ref = fields.Char("Our reference")
    note = fields.Text("Notes")
    dashboard = fields.Many2One('purchase.dashboard',"Dashboard")
    himself = fields.Function(fields.Many2One('stock.shipment.in',"Shipment"),'get_sh')
    charter_party = fields.Many2One('stock.charter.party', "Charter Party")
    owner_charter_conditions = fields.Function(
        fields.One2Many('charter.condition', '', "Owner Conditions"),
        'get_owner_charter_conditions')
    purchase_charter_conditions = fields.Function(
        fields.One2Many('charter.condition', '', "Supplier Conditions"),
        'get_purchase_charter_conditions')
    sale_charter_conditions = fields.Function(
        fields.One2Many('charter.condition', '', "Customer Conditions"),
        'get_sale_charter_conditions')
    sof = fields.One2Many('sof.statement', 'shipment',"Demurrage calculations")
    del_from = fields.Date("Shipment period from")
    del_to = fields.Date("to")
    estimated_date = fields.One2Many('pricing.estimated','shipment_in',"Estimated date")
    carrier_ = fields.Many2One('party.party',"Carrier")
    operator = fields.Many2One(
        'party.party', "Operator",
        domain=[('categories.name', '=', 'OPERATOR')])
    start_date = fields.Date("Start date")
    travel_nb = fields.Char("Travel nb")
    receive_date = fields.Date("Reception date")
    receive_nb = fields.Char("Reception nb")
    cargo_mode = fields.Selection([
        ('none', ''),
        ('bulk', 'Bulk'),
        ('container', 'Container'),
        ('tanker', 'Tanker'),
    ], 'Cargo Mode', required=True,
        states={'invisible': Eval('transport_type') == 'truck'})

    vessel_type = fields.Function(
        fields.Many2One('stock.vessel.type', "Vessel type",
            states={'invisible': Eval('transport_type') == 'truck'}),
        'get_vessel_type')
    container = fields.One2Many(
        'stock.shipment.container',
        'shipment',
        'Container'
    )
    shipment_wr = fields.One2Many('shipment.wr','shipment_in',"WR")
    controller = fields.Many2One('party.party',"Controller")
    surveyor = fields.Many2One('party.party', "Surveyor")
    controller_target = fields.Char("Targeted controller")
    send_instruction = fields.Boolean("Send instruction")
    instructions = fields.Text("Instructions")
    add_bl = fields.Boolean("Add BL")
    add_invoice = fields.Boolean("Add invoice")
    returned_id = fields.Char("Returned ID")
    result = fields.Char("Result",readonly=True)
    agent = fields.Many2One('party.party',"Booking Agent")
    service_order_key = fields.Integer("Service Order Key")

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._transitions |= set((
            ('started', 'cancelled'),
            ))
        cls._buttons.update({
            'compute': {},
            'send': {},
        })

    @staticmethod
    def _normalize_transport_values(values, record=None):
        values = dict(values)
        transport_type = values.get(
            'transport_type', getattr(record, 'transport_type', None))
        if transport_type == 'truck':
            values['vessel'] = None
            values['cargo_mode'] = 'none'
        elif values.get('cargo_mode') == 'none':
            values['cargo_mode'] = 'bulk'
        return values

    @classmethod
    def create(cls, vlist):
        return super().create([
            cls._normalize_transport_values(values) for values in vlist])

    @classmethod
    def write(cls, *args):
        new_args = cls._normalize_transport_write_args(args)
        if not new_args:
            return
        return super().write(*new_args)

    @classmethod
    def _normalize_transport_write_args(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._normalize_transport_values(values, record)])
        return new_args

    @classmethod
    def _stock_move_origin(cls, move):
        return 'stock.move,%s' % move.id

    @classmethod
    def _delete_draft_account_moves_for_stock_moves(cls, moves):
        moves = [move for move in moves if getattr(move, 'id', None)]
        if not moves:
            return

        AccountMove = Pool().get('account.move')
        account_moves = AccountMove.search([
            ('origin', 'in', [cls._stock_move_origin(move) for move in moves]),
            ])
        posted = [
            move for move in account_moves
            if getattr(move, 'state', None) == 'posted']
        if posted:
            names = ', '.join(move.rec_name for move in posted[:5])
            if len(posted) > 5:
                names += '...'
            raise UserError(
                "Cannot cancel started shipment because stock accounting "
                "move(s) are already posted: %s" % names)

        draft_moves = [
            move for move in account_moves
            if getattr(move, 'state', None) != 'posted']
        if draft_moves:
            AccountMove.delete(draft_moves)

    @classmethod
    @ModelView.button
    @Workflow.transition('cancelled')
    def cancel(cls, shipments):
        moves = [
            move for shipment in shipments
            for move in shipment.incoming_moves + shipment.inventory_moves]
        cls._delete_draft_account_moves_for_stock_moves(moves)
        super().cancel(shipments)
        started_shipments = [
            shipment for shipment in shipments
            if shipment.state == 'started']
        if started_shipments:
            cls.write(started_shipments, {
                'start_date': None,
                'started_by': None,
                })

    def get_vessel_type(self,name=None):
        if self.vessel:
            return self.vessel.vessel_type

    @fields.depends('from_location', 'to_location')
    def get_shipment_type(self, name=None):
        if (self.from_location and self.to_location
                and self.from_location.type == 'supplier'
                and self.to_location.type == 'customer'):
            return 'dropship'
        return 'inbound'

    def _get_report_primary_move(self):
        moves = list(self.incoming_moves or self.moves or [])
        return moves[0] if moves else None

    def _get_report_primary_lot(self):
        move = self._get_report_primary_move()
        return getattr(move, 'lot', None) if move else None

    def _get_report_trade_line(self):
        lot = self._get_report_primary_lot()
        if not lot:
            return None
        return getattr(lot, 'sale_line', None) or getattr(lot, 'line', None)

    def _get_report_insurance_fee(self):
        for fee in self.fees or []:
            product = getattr(fee, 'product', None)
            name = ((getattr(product, 'name', '') or '')).strip().lower()
            if 'insurance' in name:
                return fee
        return None

    def _get_report_incoming_amount_data(self):
        total = Decimal('0.0')
        currency = None
        for move in (self.incoming_moves or []):
            move_amount, move_currency = self._get_report_incoming_move_amount(
                move)
            total += move_amount
            if not currency and move_currency:
                currency = move_currency
        return total, currency

    def _get_report_incoming_move_amount(self, move):
        quantity = Decimal(str(getattr(move, 'quantity', 0) or 0))
        unit_price = getattr(move, 'unit_price', None)
        if unit_price not in (None, ''):
            move_currency = getattr(move, 'currency', None)
            return quantity * Decimal(str(unit_price or 0)), move_currency

        lot = getattr(move, 'lot', None)
        line = getattr(lot, 'line', None) if lot else None
        if not lot or not line:
            return Decimal('0.0'), None

        lot_quantity = Decimal(str(
            lot.get_current_quantity_converted() or 0))
        line_unit_price = Decimal(str(getattr(line, 'unit_price', 0) or 0))
        trade = getattr(line, 'purchase', None)
        line_currency = getattr(trade, 'currency', None) if trade else None
        return lot_quantity * line_unit_price, line_currency

    @staticmethod
    def _get_report_currency_text(currency):
        return (
            getattr(currency, 'rec_name', None)
            or getattr(currency, 'code', None)
            or getattr(currency, 'symbol', None)
            or '')

    @staticmethod
    def _format_report_amount(value):
        if value in (None, ''):
            return ''
        value = Decimal(str(value or 0)).quantize(Decimal('0.01'))
        return format(value, 'f')

    @staticmethod
    def _format_report_quantity(value, digits='0.001'):
        if value in (None, ''):
            return ''
        quantity = Decimal(str(value or 0)).quantize(Decimal(digits))
        text = format(quantity, 'f')
        return text.rstrip('0').rstrip('.') or '0'

    def _get_report_trade(self):
        line = self._get_report_trade_line()
        if not line:
            return None
        return getattr(line, 'sale', None) or getattr(line, 'purchase', None)

    @staticmethod
    def _get_report_sale_from_lot(lot):
        sale_line = getattr(lot, 'sale_line', None) if lot else None
        sale = getattr(sale_line, 'sale', None) if sale_line else None
        return sale

    def _get_report_sales(self):
        sales = []
        seen = set()

        def add_sale(sale):
            if not sale:
                return
            sale_id = getattr(sale, 'id', None)
            key = sale_id if sale_id is not None else id(sale)
            if key in seen:
                return
            seen.add(key)
            sales.append(sale)

        for move in (self.incoming_moves or self.moves or []):
            add_sale(self._get_report_sale_from_lot(
                getattr(move, 'lot', None)))
        for lot_qt in getattr(self, 'lotqt', []) or []:
            add_sale(self._get_report_sale_from_lot(
                getattr(lot_qt, 'lot_s', None)))
        return sales

    def _get_report_physical_lots(self):
        lots = []
        seen = set()
        for move in (self.incoming_moves or []):
            lot = getattr(move, 'lot', None)
            if not lot or getattr(lot, 'lot_type', None) != 'physic':
                continue
            lot_id = getattr(lot, 'id', None)
            key = lot_id if lot_id is not None else id(lot)
            if key in seen:
                continue
            seen.add(key)
            lots.append(lot)
        return lots

    def _get_report_invoice_lines_from_physical_lots(self):
        lines = []
        seen = set()
        for lot in self._get_report_physical_lots():
            for name in (
                    'sale_invoice_line',
                    'sale_invoice_line_prov',
                    'invoice_line',
                    'invoice_line_prov'):
                line = getattr(lot, name, None)
                if not line:
                    continue
                line_id = getattr(line, 'id', None)
                key = line_id if line_id is not None else id(line)
                if key in seen:
                    continue
                seen.add(key)
                lines.append(line)
                break
        return lines

    @staticmethod
    def _get_report_invoice_line_snapshot_quantity(line):
        invoice = getattr(line, 'invoice', None)
        weights = getattr(invoice, '_get_report_invoice_line_weights', None)
        if callable(weights):
            quantity, _ = weights(line)
            unit_getter = getattr(invoice, '_get_report_invoice_line_unit', None)
            unit = unit_getter(line) if callable(unit_getter) else None
            return Decimal(str(quantity or 0)), unit

        snapshots = list(getattr(line, 'lot_weight_snapshots', []) or [])
        if snapshots:
            quantity = sum(
                Decimal(str(getattr(snapshot, 'net_quantity', 0) or 0))
                for snapshot in snapshots)
            unit = getattr(snapshots[0], 'unit', None)
            return quantity, unit

        report_net = getattr(line, 'report_net', '')
        try:
            quantity = Decimal(str(report_net or 0))
        except Exception:
            quantity = Decimal(0)
        if not quantity:
            quantity = Decimal(str(getattr(line, 'quantity', 0) or 0))
        return quantity, getattr(line, 'unit', None)

    @staticmethod
    def _get_report_unit_text(unit):
        return (
            getattr(unit, 'symbol', None)
            or getattr(unit, 'rec_name', None)
            or getattr(unit, 'name', None)
            or '')

    def _get_report_insurance_invoice_quantity(self):
        total = Decimal(0)
        unit = None
        matched = False
        for line in self._get_report_invoice_lines_from_physical_lots():
            quantity, line_unit = self._get_report_invoice_line_snapshot_quantity(
                line)
            total += quantity
            if not unit and line_unit:
                unit = line_unit
            matched = True
        if not matched:
            return None, None
        return total, unit

    def _get_report_weight_totals(self):
        net = Decimal('0')
        gross = Decimal('0')
        for move in (self.incoming_moves or self.moves or []):
            lot = getattr(move, 'lot', None)
            if lot:
                lot_net = (
                    lot.get_current_quantity()
                    if hasattr(lot, 'get_current_quantity')
                    else lot.get_current_quantity_converted()
                    if hasattr(lot, 'get_current_quantity_converted')
                    else getattr(move, 'quantity', 0)
                )
                lot_gross = (
                    lot.get_current_gross_quantity()
                    if hasattr(lot, 'get_current_gross_quantity')
                    else lot_net
                )
                net += Decimal(str(lot_net or 0))
                gross += Decimal(str(lot_gross or 0))
            else:
                quantity = Decimal(str(getattr(move, 'quantity', 0) or 0))
                net += quantity
                gross += quantity
        return net, gross

    @property
    def report_product_name(self):
        line = self._get_report_trade_line()
        product = getattr(line, 'product', None) if line else None
        if product:
            return product.name or ''
        move = self._get_report_primary_move()
        product = getattr(move, 'product', None) if move else None
        return getattr(product, 'name', '') or ''

    @property
    def report_product_description(self):
        line = self._get_report_trade_line()
        product = getattr(line, 'product', None) if line else None
        if product:
            return product.description or ''
        move = self._get_report_primary_move()
        product = getattr(move, 'product', None) if move else None
        return getattr(product, 'description', '') or ''

    @property
    def report_insurance_footer_ref(self):
        return self.bl_number or self.number or ''

    @property
    def report_insurance_certificate_number(self):
        return self.bl_number or self.number or ''

    @property
    def report_insurance_order_reference(self):
        sales = self._get_report_sales()
        if len(sales) != 1:
            return ''
        sale = sales[0]
        return (
            getattr(sale, 'report_melya_proforma_number', None)
            or getattr(sale, 'report_deal', None)
            or '')

    @property
    def report_insurance_account_of(self):
        company = getattr(self, 'company', None)
        party = getattr(company, 'party', None) if company else None
        if not party:
            return ''
        address_get = getattr(party, 'address_get', None)
        address = address_get() if callable(address_get) else None
        return (
            getattr(address, 'party_name', None)
            or getattr(address, 'party_full_name', None)
            or getattr(party, 'rec_name', None)
            or '')

    @property
    def report_insurance_goods_description(self):
        name = self.report_product_name
        description = self.report_product_description
        product = (
            ' - '.join(part for part in [name, description] if part)
            if description and description != name else name or description)
        quantity, unit = self._get_report_insurance_invoice_quantity()
        if quantity is None:
            return product
        quantity_text = self._format_report_quantity(quantity, '0.0001')
        unit_text = self._get_report_unit_text(unit)
        quantity_label = ''.join(
            part for part in [quantity_text, unit_text] if part)
        return ' '.join(part for part in [quantity_label, product] if part)

    @property
    def report_insurance_invoice_number(self):
        numbers = []
        seen = set()
        for line in self._get_report_invoice_lines_from_physical_lots():
            invoice = getattr(line, 'invoice', None)
            number = (
                getattr(invoice, 'number', None)
                or getattr(invoice, 'rec_name', None)
                or '')
            if not number or number in seen:
                continue
            seen.add(number)
            numbers.append(number)
        return ' '.join(numbers)

    @property
    def report_insurance_loading_port(self):
        return getattr(self.from_location, 'name', '') or ''

    @property
    def report_insurance_discharge_port(self):
        return getattr(self.to_location, 'name', '') or ''

    @property
    def report_insurance_transport(self):
        if self.vessel and self.vessel.vessel_name:
            return self.vessel.vessel_name
        return self.transport_type or ''

    @property
    def report_insurance_amount(self):
        insured_amount, insured_currency = self._get_report_incoming_amount_data()
        if insured_amount:
            insured_amount *= Decimal('1.10')
            currency_text = self._get_report_currency_text(insured_currency)
            amount_text = self._format_report_amount(insured_amount)
            return ' '.join(part for part in [currency_text, amount_text] if part)

        fee = self._get_report_insurance_fee()
        if not fee:
            return ''
        currency = getattr(fee, 'currency', None)
        currency_text = self._get_report_currency_text(currency)
        amount = self._format_report_amount(fee.get_amount())
        return ' '.join(part for part in [currency_text, amount] if part)

    @property
    def report_insurance_incoming_amount(self):
        amount, currency = self._get_report_incoming_amount_data()
        currency_text = self._get_report_currency_text(currency)
        amount_text = self._format_report_amount(amount)
        return ' '.join(part for part in [currency_text, amount_text] if part)

    @property
    def report_insurance_amount_insured(self):
        amount, currency = self._get_report_incoming_amount_data()
        insured_amount = amount * Decimal('1.10')
        currency_text = self._get_report_currency_text(currency)
        amount_text = self._format_report_amount(insured_amount)
        return ' '.join(part for part in [currency_text, amount_text] if part)

    @property
    def report_insurance_surveyor(self):
        if self.surveyor:
            return self.surveyor.rec_name or ''
        if self.controller:
            return self.controller.rec_name or ''
        fee = self._get_report_insurance_fee()
        supplier = getattr(fee, 'supplier', None) if fee else None
        return getattr(supplier, 'rec_name', '') or ''

    def _get_report_insurance_surveyor_party(self):
        if self.surveyor:
            return self.surveyor
        if self.controller:
            return self.controller
        fee = self._get_report_insurance_fee()
        return getattr(fee, 'supplier', None) if fee else None

    @staticmethod
    def _normalize_report_address_line(value):
        return ' '.join(str(value or '').split()).strip().casefold()

    @classmethod
    def _get_report_party_address_lines(cls, party):
        if not party:
            return []
        name = getattr(party, 'rec_name', '') or ''
        lines = [name] if name else []
        address_get = getattr(party, 'address_get', None)
        address = address_get() if address_get else None
        if not address:
            return lines

        duplicate_names = {
            cls._normalize_report_address_line(value)
            for value in [
                name,
                getattr(party, 'full_name', None),
                getattr(address, 'party_name', None),
                getattr(address, 'party_full_name', None),
            ]
            if value
        }
        full_address = getattr(address, 'full_address', '') or ''
        if not isinstance(full_address, str):
            return lines
        address_lines = [
            line.strip()
            for line in full_address.splitlines()
            if line.strip()
        ]
        for line in address_lines:
            if cls._normalize_report_address_line(line) in duplicate_names:
                continue
            lines.append(line)
        return lines

    @property
    def report_insurance_contact_surveyor(self):
        party = self._get_report_insurance_surveyor_party()
        return '\n'.join(self._get_report_party_address_lines(party))

    @property
    def report_insurance_issue_place_and_date(self):
        Date = Pool().get('ir.date')
        address = None
        if self.company and getattr(self.company, 'party', None):
            address = self.company.party.address_get()
        place = (
            getattr(address, 'city', None)
            or getattr(self.company.party, 'rec_name', None)
            if self.company and getattr(self.company, 'party', None) else ''
        ) or ''
        today = Date.today()
        date_text = today.strftime('%d-%m-%Y') if today else ''
        return ', '.join(part for part in [place, date_text] if part)

    @property
    def report_packing_product_class(self):
        return self.report_product_name

    @property
    def report_packing_contract_number(self):
        trade = self._get_report_trade()
        return (
            getattr(trade, 'reference', None)
            or getattr(trade, 'number', None)
            or self.reference
            or self.number
            or '')

    @property
    def report_packing_invoice_qty(self):
        quantity = self.quantity if self.quantity not in (None, '') else 0
        return self._format_report_quantity(quantity)

    @property
    def report_packing_invoice_qty_unit(self):
        unit = self.unit
        return (
            getattr(unit, 'symbol', None)
            or getattr(unit, 'rec_name', None)
            or '')

    @property
    def report_packing_origin(self):
        trade = self._get_report_trade()
        return (
            getattr(trade, 'product_origin', None)
            or getattr(self.from_location, 'name', None)
            or '')

    @property
    def report_packing_product(self):
        return self.report_product_name

    @property
    def report_packing_counterparty_name(self):
        trade = self._get_report_trade()
        party = getattr(trade, 'party', None) if trade else None
        if party:
            return party.rec_name or ''
        return getattr(self.supplier, 'rec_name', '') or ''

    @property
    def report_packing_ship_name(self):
        if self.vessel and self.vessel.vessel_name:
            return self.vessel.vessel_name
        return self.transport_type or ''

    @property
    def report_packing_loading_port(self):
        return getattr(self.from_location, 'name', '') or ''

    @property
    def report_packing_destination_port(self):
        return getattr(self.to_location, 'name', '') or ''

    @property
    def report_packing_chunk_number(self):
        return self.bl_number or self.number or ''

    @property
    def report_packing_chunk_date(self):
        if self.bl_date:
            return self.bl_date.strftime('%d-%m-%Y')
        return ''

    @property
    def report_packing_today_date(self):
        Date = Pool().get('ir.date')
        today = Date.today()
        if not today:
            return ''
        return f"{today.strftime('%B')} {today.day}, {today.year}"

    @property
    def report_packing_weight_unit(self):
        line = self._get_report_trade_line()
        unit = getattr(line, 'unit', None) if line else None
        if unit:
            return (
                getattr(unit, 'symbol', None)
                or getattr(unit, 'rec_name', None)
                or '')
        return self.report_packing_invoice_qty_unit or 'KGS'

    @property
    def report_packing_gross_weight(self):
        _, gross = self._get_report_weight_totals()
        return self._format_report_quantity(gross)

    @property
    def report_packing_net_weight(self):
        net, _ = self._get_report_weight_totals()
        return self._format_report_quantity(net)

    @property
    def report_coo_exporter(self):
        company = getattr(self, 'company', None)
        party = getattr(company, 'party', None) if company else None
        if not party:
            return ''
        address = party.address_get() if hasattr(party, 'address_get') else None
        lines = [getattr(party, 'rec_name', None) or getattr(party, 'name', None) or '']
        if address and getattr(address, 'full_address', None):
            lines.append(address.full_address)
        return '\n'.join(filter(None, lines))

    @property
    def report_coo_consignee(self):
        trade = self._get_report_trade()
        party = getattr(trade, 'party', None) if trade else None
        if not party:
            return ''
        address = party.address_get() if hasattr(party, 'address_get') else None
        lines = [getattr(party, 'rec_name', None) or getattr(party, 'name', None) or '']
        if address and getattr(address, 'full_address', None):
            lines.append(address.full_address)
        return '\n'.join(filter(None, lines))

    @property
    def report_coo_number(self):
        return getattr(self, 'reference', None) or self.number or ''

    @property
    def report_coo_transport(self):
        parts = []
        if self.bl_number:
            parts.append(f"B/L {self.bl_number}")
        ship_name = self.report_packing_ship_name
        if ship_name:
            parts.append(ship_name)
        if self.booking:
            parts.append(f"Booking {self.booking}")
        return ' - '.join(parts)

    @property
    def report_coo_origin_country(self):
        return self.report_packing_origin or ''

    @property
    def report_coo_observations(self):
        parts = []
        contract = self.report_packing_contract_number
        if contract:
            parts.append(f"Contract: {contract}")
        if self.note:
            parts.append(self.note)
        return '\n'.join(filter(None, parts))

    @property
    def report_coo_goods_description(self):
        parts = [self.report_product_name, self.report_product_description]
        if self.container:
            container_numbers = ', '.join(
                filter(None, (getattr(c, 'container_no', None) or '' for c in self.container)))
            if container_numbers:
                parts.append(f"Container(s): {container_numbers}")
        return '\n'.join(filter(None, parts))

    @property
    def report_coo_net_weight(self):
        return self.report_packing_net_weight

    @property
    def report_coo_gross_weight(self):
        return self.report_packing_gross_weight

    @property
    def report_coo_weight_unit(self):
        return self.report_packing_weight_unit or ''

    @property
    def report_coo_net_weight_display(self):
        parts = [self.report_coo_net_weight, self.report_coo_weight_unit]
        return ' '.join(part for part in parts if part)

    @property
    def report_coo_gross_weight_display(self):
        parts = [self.report_coo_gross_weight, self.report_coo_weight_unit]
        return ' '.join(part for part in parts if part)

    @property
    def report_coo_issue_date(self):
        Date = Pool().get('ir.date')
        today = Date.today()
        if not today:
            return ''
        return today.strftime('%d-%m-%Y')

    @staticmethod
    def _report_record_key(record):
        record_id = getattr(record, 'id', None)
        return record_id if record_id is not None else id(record)

    @classmethod
    def _report_unique_records(cls, records):
        unique = []
        seen = set()
        for record in records:
            if not record:
                continue
            key = cls._report_record_key(record)
            if key in seen:
                continue
            seen.add(key)
            unique.append(record)
        return unique

    def _get_report_linkage_lots(self):
        return self._report_unique_records(
            getattr(move, 'lot', None)
            for move in (self.incoming_moves or self.moves or []))

    @staticmethod
    def _report_linkage_lot_purchase_line(lot):
        line = getattr(lot, 'line', None)
        if line:
            return line
        get_virtual_lot = getattr(lot, 'getVlot_p', None)
        if callable(get_virtual_lot):
            try:
                virtual_lot = get_virtual_lot()
            except (IndexError, AttributeError):
                virtual_lot = None
            return getattr(virtual_lot, 'line', None)
        return None

    @staticmethod
    def _report_linkage_lot_sale_line(lot):
        line = getattr(lot, 'sale_line', None)
        if line:
            return line
        get_virtual_lot = getattr(lot, 'getVlot_s', None)
        if callable(get_virtual_lot):
            try:
                virtual_lot = get_virtual_lot()
            except (IndexError, AttributeError):
                virtual_lot = None
            return getattr(virtual_lot, 'sale_line', None)
        return None

    def _get_report_linkage_purchase_lines(self):
        return self._report_unique_records(
            self._report_linkage_lot_purchase_line(lot)
            for lot in self._get_report_linkage_lots())

    def _get_report_linkage_sale_lines(self):
        return self._report_unique_records(
            self._report_linkage_lot_sale_line(lot)
            for lot in self._get_report_linkage_lots())

    def _get_report_linkage_purchase(self):
        purchases = self._report_unique_records(
            getattr(line, 'purchase', None)
            for line in self._get_report_linkage_purchase_lines())
        return purchases[0] if purchases else None

    def _get_report_linkage_sale(self):
        sales = self._report_unique_records(
            getattr(line, 'sale', None)
            for line in self._get_report_linkage_sale_lines())
        return sales[0] if sales else None

    @staticmethod
    def _report_rec_name(record):
        return (
            getattr(record, 'rec_name', None)
            or getattr(record, 'name', None)
            or getattr(record, 'number', None)
            or '')

    @classmethod
    def _report_number(cls, record):
        return (
            getattr(record, 'number', None)
            or getattr(record, 'full_number', None)
            or cls._report_rec_name(record)
            or '')

    @staticmethod
    def _report_date(value):
        if not value:
            return ''
        if hasattr(value, 'strftime'):
            return value.strftime('%d %b %y')
        return str(value)

    @staticmethod
    def _report_amount(value):
        if value in (None, ''):
            return ''
        return format(Decimal(str(value or 0)).quantize(Decimal('0.001')), ',f')

    @staticmethod
    def _report_price(value):
        if value in (None, ''):
            return ''
        return format(Decimal(str(value or 0)).quantize(Decimal('0.001')), 'f')

    @classmethod
    def _report_currency_code(cls, record):
        currency = getattr(record, 'currency', None)
        for name in ('code', 'rec_name', 'name', 'symbol'):
            value = getattr(currency, name, None)
            if value and not str(value).isdigit():
                return value
        return ''

    @classmethod
    def _report_unit_symbol(cls, line):
        unit = getattr(line, 'unit', None)
        return (
            getattr(unit, 'symbol', None)
            or getattr(unit, 'rec_name', None)
            or getattr(unit, 'name', None)
            or '')

    @classmethod
    def _report_line_quantity(cls, line):
        return Decimal(str(
            getattr(line, 'quantity_theorical', None)
            or getattr(line, 'quantity', 0)
            or 0))

    @classmethod
    def _report_line_amount(cls, line):
        quantity = cls._report_line_quantity(line)
        price = Decimal(str(getattr(line, 'unit_price', 0) or 0))
        return quantity * price

    def _get_report_linkage_product(self):
        lines = (
            self._get_report_linkage_purchase_lines()
            or self._get_report_linkage_sale_lines())
        product = getattr(lines[0], 'product', None) if lines else None
        if product:
            return product
        move = self._get_report_primary_move()
        return getattr(move, 'product', None) if move else None

    def _get_report_linkage_fees(self):
        fees = []
        seen = set()

        def add_fee(fee):
            if not fee:
                return
            key = self._report_record_key(fee)
            if key in seen:
                return
            seen.add(key)
            fees.append(fee)

        for fee in getattr(self, 'fees', []) or []:
            add_fee(fee)
        for line in (
                self._get_report_linkage_purchase_lines()
                + self._get_report_linkage_sale_lines()):
            for fee in getattr(line, 'fees', []) or []:
                add_fee(fee)
        return [
            fee for fee in fees
            if self._report_linkage_fee_applies_to_report_lots(fee)]

    def _get_report_linkage_fee_lots(self, fee):
        get_lots = getattr(fee, '_get_effective_fee_lots', None)
        if callable(get_lots):
            lots = get_lots()
            if lots:
                return list(lots)
        lots = getattr(fee, 'lots', None)
        if lots:
            return list(lots)
        return []

    def _report_linkage_fee_applies_to_report_lots(self, fee):
        report_lots = self._get_report_linkage_lots()
        if not report_lots:
            return True
        fee_lots = self._get_report_linkage_fee_lots(fee)
        if fee_lots:
            report_lot_keys = {
                self._report_record_key(lot) for lot in report_lots}
            return any(
                self._report_record_key(lot) in report_lot_keys
                for lot in fee_lots)

        trade_line = self._get_report_linkage_fee_trade_line(fee)
        report_lines = (
            self._get_report_linkage_purchase_lines()
            + self._get_report_linkage_sale_lines())
        report_line_keys = {
            self._report_record_key(line) for line in report_lines}
        return (
            not trade_line
            or self._report_record_key(trade_line) in report_line_keys)

    def _get_report_linkage_fee_trade_line(self, fee):
        return (
            getattr(fee, 'line', None)
            or getattr(fee, 'sale_line', None)
            or (self._get_report_linkage_purchase_lines()
                or self._get_report_linkage_sale_lines()
                or [None])[0])

    def _get_report_linkage_fee_trade(self, fee):
        trade_line = self._get_report_linkage_fee_trade_line(fee)
        return (
            getattr(trade_line, 'purchase', None)
            or getattr(trade_line, 'sale', None)
            or getattr(fee, 'purchase', None)
            or getattr(fee, 'sale', None))

    @classmethod
    def _report_linkage_fee_type(cls, fee):
        return getattr(fee, 'type', None) or 'budgeted'

    def _get_report_linkage_fee_pairs(self):
        groups = {}
        order = []
        fallback_trade = (
            self._get_report_linkage_purchase()
            or self._get_report_linkage_sale())

        for fee in self._get_report_linkage_fees():
            trade_line = self._get_report_linkage_fee_trade_line(fee)
            trade = self._get_report_linkage_fee_trade(fee) or fallback_trade
            key = (
                self._report_record_key(trade_line or trade),
                self._report_record_key(getattr(fee, 'product', None)),
                self._report_record_key(getattr(fee, 'supplier', None)),
                )
            if key not in groups:
                groups[key] = {
                    'trade': trade,
                    'budgeted': [],
                    'ordered': [],
                }
                order.append(key)
            fee_type = self._report_linkage_fee_type(fee)
            if fee_type in ('budgeted', 'ordered'):
                groups[key][fee_type].append(fee)

        pairs = []
        for key in order:
            group = groups[key]
            budgeted = group['budgeted']
            if not budgeted:
                continue
            ordered = group['ordered']
            estimated = sum(
                (self._report_fee_amount(fee) for fee in budgeted),
                Decimal('0'))
            validated = estimated
            if ordered:
                validated = sum(
                    (self._report_fee_amount(fee) for fee in ordered),
                    Decimal('0'))
            pairs.append({
                    'fee': budgeted[0],
                    'trade': group['trade'],
                    'estimated': estimated,
                    'validated': validated,
                    'ordered': ordered,
                    })
        return pairs

    @classmethod
    def _report_fee_amount(cls, fee):
        get_amount = getattr(fee, 'get_amount', None)
        if callable(get_amount):
            amount = get_amount()
        else:
            amount = None
        if amount in (None, ''):
            price = Decimal(str(getattr(fee, 'price', 0) or 0))
            quantity = Decimal(str(getattr(fee, 'quantity', 0) or 0))
            mode = getattr(fee, 'mode', None)
            amount = price if mode == 'lumpsum' else price * quantity
        amount = Decimal(str(amount or 0))
        return -amount if getattr(fee, 'p_r', None) == 'pay' else amount

    @classmethod
    def _report_linkage_fee_label(cls, fee):
        product = getattr(fee, 'product', None)
        label = (
            getattr(product, 'name', None)
            or cls._report_rec_name(product)
            or 'Fee')
        if label.startswith('[') and '] ' in label:
            label = label.split('] ', 1)[1]
        return label

    def _get_report_linkage_summary_rows(self):
        purchase_lines = self._get_report_linkage_purchase_lines()
        sale_lines = self._get_report_linkage_sale_lines()
        purchase_total = -sum(
            self._report_line_amount(line) for line in purchase_lines)
        sale_total = sum(self._report_line_amount(line) for line in sale_lines)

        rows = []
        if purchase_lines:
            rows.append((
                    'Purchases', '', purchase_total, purchase_total,
                    'major'))
            rows.append(('', '', '', '', 'spacer'))
        if sale_lines:
            rows.append(('Sales', '', sale_total, sale_total, 'major'))
            rows.append(('', '', '', '', 'spacer'))

        fee_estimated_total = Decimal('0')
        fee_validated_total = Decimal('0')
        fee_rows = {}
        for pair in self._get_report_linkage_fee_pairs():
            fee = pair['fee']
            label = self._report_linkage_fee_label(fee)
            estimated = pair['estimated']
            validated = pair['validated']
            fee_estimated_total += estimated
            fee_validated_total += validated
            current = fee_rows.get(label, (Decimal('0'), Decimal('0')))
            fee_rows[label] = (
                current[0] + estimated,
                current[1] + validated,
                )
        for label, amounts in fee_rows.items():
            rows.append((
                    'Costs', label, amounts[0], amounts[1], 'cost'))

        if rows:
            rows.append((
                    'Costs', 'Total', fee_estimated_total,
                    fee_validated_total, 'cost'))
            rows.append(('', '', '', '', 'spacer'))
            estimated_pnl = purchase_total + sale_total + fee_estimated_total
            validated_pnl = purchase_total + sale_total + fee_validated_total
            rows.append(('P&L', '', estimated_pnl, validated_pnl, 'pnl'))
        return rows

    def _get_report_linkage_movement_rows(self):
        rows = []
        purchase_lines = self._get_report_linkage_purchase_lines()
        sale_lines = self._get_report_linkage_sale_lines()
        if purchase_lines:
            rows.append(('Purchase', '', '', '', '', '', '', '', '', ''))
            for line in purchase_lines:
                purchase = getattr(line, 'purchase', None)
                rows.append(self._get_report_linkage_movement_row(
                    'Deal', purchase, line))
        if sale_lines:
            rows.append(('Sale', '', '', '', '', '', '', '', '', ''))
            for line in sale_lines:
                sale = getattr(line, 'sale', None)
                rows.append(self._get_report_linkage_movement_row(
                    'Deal', sale, line))
        if purchase_lines or sale_lines:
            balance = (
                sum(self._report_line_quantity(line) for line in purchase_lines)
                - sum(self._report_line_quantity(line) for line in sale_lines))
            unit = self._report_unit_symbol(
                purchase_lines[0] if purchase_lines else sale_lines[0])
            rows.append((
                    'Balance (Gain/Loss)', '', '', '',
                    ' '.join(part for part in [
                        self._format_report_quantity(balance),
                        unit.upper() if unit else ''] if part),
                    '', '', '', '', ''))
        return rows

    def _get_report_linkage_movement_row(self, kind, trade, line):
        product = getattr(line, 'product', None)
        quantity = ' '.join(part for part in [
            self._format_report_quantity(self._report_line_quantity(line)),
            (self._report_unit_symbol(line) or '').upper()] if part)
        incoterm = getattr(trade, 'incoterm', None)
        period = getattr(line, 'period_at', None) or ''
        if period == 'laycan':
            period = 'Laycan'
        return (
            kind,
            self._report_number(trade),
            self._report_rec_name(getattr(trade, 'party', None)),
            self._report_rec_name(product),
            quantity,
            getattr(incoterm, 'code', None) or '',
            self._report_rec_name(getattr(trade, 'to_location', None)),
            period,
            self._report_date(getattr(line, 'from_del', None)),
            self._report_date(getattr(line, 'to_del', None)),
            )

    def _get_report_linkage_pricing_rows(self):
        rows = []
        for label, lines in (
                ('Buy', self._get_report_linkage_purchase_lines()),
                ('Sell', self._get_report_linkage_sale_lines())):
            for line in lines:
                trade = (
                    getattr(line, 'purchase', None)
                    or getattr(line, 'sale', None))
                currency = self._report_currency_code(trade)
                unit = self._report_unit_symbol(line).upper()
                price = self._report_price(getattr(line, 'unit_price', 0))
                rows.append((
                    self._report_number(trade),
                    label,
                    (getattr(line, 'price_type', None) or 'Fixed').title(),
                    '',
                    self._report_rec_name(getattr(line, 'del_period', None)),
                    ' '.join(part for part in [
                        price,
                        '/'.join(part for part in [currency, unit] if part)]
                        if part),
                    ))
        return rows

    def _get_report_linkage_detail_rows(self):
        rows = []
        for line in self._get_report_linkage_sale_lines():
            sale = getattr(line, 'sale', None)
            rows.append(self._get_report_linkage_detail_row(
                'A', 'Deal revenue', sale, line, self._report_line_amount(line)))
        for line in self._get_report_linkage_purchase_lines():
            purchase = getattr(line, 'purchase', None)
            rows.append(self._get_report_linkage_detail_row(
                'A', 'Deal expense', purchase, line,
                -self._report_line_amount(line)))
        for pair in self._get_report_linkage_fee_pairs():
            rows.append(self._get_report_linkage_fee_detail_row(pair))
        estimated_total = sum((row[6] for row in rows), Decimal('0'))
        validated_total = sum((row[7] for row in rows), Decimal('0'))
        if rows:
            rows.append((
                    'A', 'Total', '', '', '', '', estimated_total,
                    validated_total, 'total'))
            rows.append((
                    'P&L', '', '', '', '', '', estimated_total,
                    validated_total, 'pnl'))
        return rows

    def _get_report_linkage_detail_row(self, group, label, trade, line, amount):
        currency = self._report_currency_code(trade)
        unit = self._report_unit_symbol(line).upper()
        unit_price = self._report_price(getattr(line, 'unit_price', 0))
        quantity = self._format_report_quantity(self._report_line_quantity(line))
        return (
            group,
            label,
            self._report_rec_name(getattr(trade, 'party', None)),
            self._report_number(trade),
            ' '.join(part for part in [
                unit_price,
                '/'.join(part for part in [currency, unit] if part)]
                if part),
            quantity,
            amount,
            amount,
            'line',
            )

    def _get_report_linkage_fee_detail_row(self, pair):
        fee = pair['fee']
        product = getattr(fee, 'product', None)
        trade_line = self._get_report_linkage_fee_trade_line(fee)
        trade = pair.get('trade') or self._get_report_linkage_fee_trade(fee)
        currency = self._report_currency_code(fee) or self._report_currency_code(trade)
        unit = self._get_report_unit_text(getattr(fee, 'unit', None))
        quantity = getattr(fee, 'quantity', None)
        if quantity in (None, '') and trade_line:
            quantity = self._report_line_quantity(trade_line)
        return (
            'A',
            self._report_linkage_fee_label(fee),
            self._report_rec_name(getattr(fee, 'supplier', None)),
            self._report_number(trade),
            ' '.join(part for part in [
                self._report_price(getattr(fee, 'price', 0)),
                '/'.join(part for part in [currency, unit.upper() if unit else ''] if part)]
                if part),
            self._format_report_quantity(quantity or 0),
            pair['estimated'],
            pair['validated'],
            'fee',
            )

    @classmethod
    def _report_column(cls, rows, index, amount=False):
        values = []
        for row in rows:
            value = row[index]
            if amount:
                value = cls._report_amount(value)
            values.append(str(value or ''))
        return '\n'.join(values)

    @classmethod
    def _report_table_rows(cls, rows, names, amount_names=()):
        result = []
        for row in rows:
            values = {}
            for index, name in enumerate(names):
                value = row[index]
                if name in amount_names:
                    value = cls._report_amount(value)
                values[name] = str(value or '')
            result.append(values)
        return result

    @property
    def report_linkage_title(self):
        purchase = self._get_report_linkage_purchase()
        sale = self._get_report_linkage_sale()
        reference = (
            getattr(purchase, 'reference', None)
            or getattr(sale, 'reference', None)
            or getattr(self, 'reference', None)
            or '')
        numbers = '/'.join(filter(None, [
            self._report_number(purchase),
            self._report_number(sale),
            ]))
        vessel = self._report_rec_name(getattr(self, 'vessel', None))
        return ' '.join(part for part in [
                'Linkage', numbers, reference, vessel] if part)

    @property
    def report_linkage_desk(self):
        return 'Sulfuric Acid'

    @property
    def report_linkage_book(self):
        purchase_lines = self._get_report_linkage_purchase_lines()
        product = (
            getattr(purchase_lines[0], 'product', None)
            if purchase_lines else self._get_report_linkage_product())
        code = getattr(product, 'code', None) if product else ''
        period = self.report_linkage_strategy
        return ' '.join(part for part in [code, period] if part)

    @property
    def report_linkage_strategy(self):
        for line in (
                self._get_report_linkage_purchase_lines()
                or self._get_report_linkage_sale_lines()):
            strategies = list(getattr(line, 'mtm', []) or [])
            if strategies:
                strategy = getattr(strategies[0], 'strategy', None) or strategies[0]
                return self._report_rec_name(strategy)
            period = getattr(line, 'del_period', None)
            if period:
                return self._report_rec_name(period)
        return ''

    @property
    def report_linkage_bl_date(self):
        return self.bl_date and self.bl_date.strftime('%A, %B %d, %Y') or ''

    @property
    def report_linkage_trading_unit(self):
        purchase = self._get_report_linkage_purchase()
        company = getattr(purchase, 'company', None) if purchase else None
        if not company:
            company = getattr(self, 'company', None)
        party = getattr(company, 'party', None) if company else None
        if party:
            return self._report_rec_name(party)
        sale = self._get_report_linkage_sale()
        party = getattr(sale, 'party', None) if sale else None
        return self._report_rec_name(party)

    @property
    def report_linkage_finance_user(self):
        trade = self._get_report_linkage_purchase() or self._get_report_linkage_sale()
        operator = getattr(trade, 'operator', None) if trade else None
        return self._report_rec_name(operator)

    @property
    def report_linkage_summary_groups(self):
        return self._report_column(self._get_report_linkage_summary_rows(), 0)

    @property
    def report_linkage_summary_cost_types(self):
        return self._report_column(self._get_report_linkage_summary_rows(), 1)

    @property
    def report_linkage_summary_estimated(self):
        return self._report_column(
            self._get_report_linkage_summary_rows(), 2, amount=True)

    @property
    def report_linkage_summary_validated(self):
        return self._report_column(
            self._get_report_linkage_summary_rows(), 3, amount=True)

    @property
    def report_linkage_summary_rows(self):
        return self._report_table_rows(
            self._get_report_linkage_summary_rows(),
            ('group', 'cost_type', 'estimated', 'validated', 'kind'),
            {'estimated', 'validated'})

    @property
    def report_linkage_movement_types(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 0)

    @property
    def report_linkage_movement_references(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 1)

    @property
    def report_linkage_movement_counterparts(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 2)

    @property
    def report_linkage_movement_commodities(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 3)

    @property
    def report_linkage_movement_quantities(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 4)

    @property
    def report_linkage_movement_deliveries(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 5)

    @property
    def report_linkage_movement_basis(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 6)

    @property
    def report_linkage_movement_periods(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 7)

    @property
    def report_linkage_movement_from_dates(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 8)

    @property
    def report_linkage_movement_to_dates(self):
        return self._report_column(self._get_report_linkage_movement_rows(), 9)

    @property
    def report_linkage_movement_rows(self):
        return self._report_table_rows(
            self._get_report_linkage_movement_rows(),
            ('type', 'reference', 'counterpart', 'commodity', 'quantity',
                'delivery', 'basis', 'period', 'from_date', 'to_date'))

    @property
    def report_linkage_bank_deal(self):
        purchase = self._get_report_linkage_purchase()
        return self._report_number(purchase)

    @property
    def report_linkage_bank_type(self):
        return 'Buy' if self._get_report_linkage_purchase() else ''

    @property
    def report_linkage_bank_name(self):
        sale = self._get_report_linkage_sale()
        bank = getattr(sale, 'our_bank_account', None) if sale else None
        bank_party = getattr(bank, 'bank', None) if bank else None
        return self._report_rec_name(bank_party)

    @property
    def report_linkage_lc_type(self):
        return 'N/A'

    @property
    def report_linkage_lc_number(self):
        sale = self._get_report_linkage_sale()
        return getattr(sale, 'lc_number', '') or ''

    @property
    def report_linkage_lc_amount(self):
        return ''

    @property
    def report_linkage_lc_expiry_date(self):
        sale = self._get_report_linkage_sale()
        return self._report_date(getattr(sale, 'lc_date', None))

    @property
    def report_linkage_pricing_deals(self):
        return self._report_column(self._get_report_linkage_pricing_rows(), 0)

    @property
    def report_linkage_pricing_sides(self):
        return self._report_column(self._get_report_linkage_pricing_rows(), 1)

    @property
    def report_linkage_pricing_types(self):
        return self._report_column(self._get_report_linkage_pricing_rows(), 2)

    @property
    def report_linkage_pricing_symbols(self):
        return self._report_column(self._get_report_linkage_pricing_rows(), 3)

    @property
    def report_linkage_pricing_periods(self):
        return self._report_column(self._get_report_linkage_pricing_rows(), 4)

    @property
    def report_linkage_pricing_input_prices(self):
        return self._report_column(self._get_report_linkage_pricing_rows(), 5)

    @property
    def report_linkage_pricing_rows(self):
        return self._report_table_rows(
            self._get_report_linkage_pricing_rows(),
            ('deal', 'side', 'type', 'symbol', 'period', 'input_price'))

    @property
    def report_linkage_detail_groups(self):
        return self._report_column(self._get_report_linkage_detail_rows(), 0)

    @property
    def report_linkage_detail_cost_types(self):
        return self._report_column(self._get_report_linkage_detail_rows(), 1)

    @property
    def report_linkage_detail_counterparts(self):
        return self._report_column(self._get_report_linkage_detail_rows(), 2)

    @property
    def report_linkage_detail_sources(self):
        return self._report_column(self._get_report_linkage_detail_rows(), 3)

    @property
    def report_linkage_detail_unit_prices(self):
        return self._report_column(self._get_report_linkage_detail_rows(), 4)

    @property
    def report_linkage_detail_quantities(self):
        return self._report_column(self._get_report_linkage_detail_rows(), 5)

    @property
    def report_linkage_detail_estimated(self):
        return self._report_column(
            self._get_report_linkage_detail_rows(), 6, amount=True)

    @property
    def report_linkage_detail_validated(self):
        return self._report_column(
            self._get_report_linkage_detail_rows(), 7, amount=True)

    @property
    def report_linkage_detail_rows(self):
        return self._report_table_rows(
            self._get_report_linkage_detail_rows(),
            ('group', 'cost_type', 'counterpart', 'source', 'unit_price',
                'quantity', 'estimated', 'validated', 'kind'),
            {'estimated', 'validated'})

    def get_rec_name(self, name=None):
        if self.number:
            return self.number + '[' + (self.vessel.vessel_name if self.vessel else '') +  (('-' + self.travel_nb) if self.travel_nb else '') + ']'
        else:
            return str(self.id)

    def create_fee(self,controller):
        Fee = Pool().get('fee.fee')
        Product = Pool().get('product.product')
        if not controller:
            logger.info("CREATE_FEE_SKIPPED:NO_CONTROLLER shipment=%s", self)
            return
        fee = Fee()
        fee.shipment_in = self.id
        fee.supplier = controller
        fee.type = 'budgeted'
        fee.p_r = 'pay'
        price,mode,curr,unit = controller.get_sla_cost(self.to_location)
        if not (price and mode and curr and unit):
            logger.info(
                "CREATE_FEE_SKIPPED:NO_SLA shipment=%s controller=%s location=%s",
                self, controller, self.to_location)
            return
        fee.mode = mode
        fee.currency = curr
        fee.unit = unit
        fee.quantity = self.get_bales() or 1
        fee.product = Product.get_by_name('Reweighing')
        fee.price = price
        Fee.save([fee])

    def get_controller(self):
        ControllerCategory = Pool().get('party.category')
        PartyCategory = Pool().get('party.party-party.category')
        cc = ControllerCategory.search(['name','=','CONTROLLER'])
        if cc:
            cc = cc[0]
            controllers = PartyCategory.search(['category','=',cc.id])
            prioritized = []
            for c in controllers:
                if not c.party.IsAvailableForControl(self):
                    continue
                gap, rule = c.party.get_controller_execution_priority(self)
                prioritized.append((
                        1 if rule else 0,
                        gap if gap is not None else Decimal('-999999'),
                        c.party,
                        ))
            if prioritized:
                prioritized.sort(key=lambda item: (item[0], item[1]), reverse=True)
                return prioritized[0][2]
    
    def get_instructions_html(self,inv_date,inv_nb):
        vessel = self.vessel.vessel_name if self.vessel else ""
        lines = [
            "<p>Hi,</p>",
            "<p>Please find details below for the requested control</p>",
        ]
        lines.append(
                "<p>"
                f"<strong>BL number:</strong> {self.bl_number} | "
                f"<strong>Vessel:</strong> {vessel} | "
                f"<strong>ETA:</strong> {self.etad}"
                "</p>"
            )

        if self.incoming_moves:
            tot_net = sum([m.lot.get_current_quantity() for m in self.incoming_moves])
            tot_gross = sum([m.lot.get_current_gross_quantity() for m in self.incoming_moves])
            tot_bale = sum([m.lot.lot_qt for m in self.incoming_moves])
            customer = self.incoming_moves[0].lot.sale_line.sale.party.name if self.incoming_moves[0].lot.sale_line else ""
            unit = self.incoming_moves[0].lot.lot_unit_line.symbol
            lines.append("<p>"
                f"<strong>Customer:</strong> {customer} | "
                f"<strong>Invoice Nb:</strong> {inv_nb} | "
                f"<strong>Invoice Date:</strong> {inv_date}"
                "</p>"
            )
            lines.append(
                "<p>"
                f"<strong>Nb Bales:</strong> {tot_bale} | "
                f"<strong>Net Qt:</strong> {tot_net} {unit} | "
                f"<strong>Gross Qt:</strong> {tot_gross} {unit}"
                "</p>"
            )

        return "".join(lines)

    # def get_instructions(self):
    #     lines = [
    #         "Hi,",
    #         "",
    #         "Please find details below for the requested control",
    #         f"BL number: {self.bl_number}",
    #         ""
    #     ]

    #     if self.incoming_moves:
    #         for m in self.incoming_moves:
    #             if m.lot:
    #                 lines.append(
    #                     f"Lot nb: {m.lot.lot_name} | "
    #                     f"Net Qt: {m.lot.get_current_quantity()} {m.lot.lot_unit.symbol} | "
    #                     f"Gross Qt: {m.lot.get_current_gross_quantity()} {m.lot.lot_unit.symbol}"
    #                 )

    #     return "\n".join(lines)
            
    def _create_lots_from_fintrade(self):
        t = Table('freight_booking_lots')
        cursor = Transaction().connection.cursor()
        query = t.select(
            t.BOOKING_NUMBER,
            t.LOT_NUMBER,
            t.LOT_NBR_BALES,
            t.LOT_GROSS_WEIGHT,
            t.LOT_NET_WEIGHT,
            t.LOT_UOM,
            t.LOT_QUALITY,
            t.CUSTOMER,
            t.SELL_PRICE_CURRENCY,
            t.SELL_PRICE_UNIT,
            t.SELL_PRICE,
            t.SALE_INVOICE,
            t.SELL_INV_AMOUNT,
            t.SALE_INVOICE_DATE,
            t.SELL_PREMIUM,
            t.SALE_CONTRACT_NUMBER,
            t.SALE_DECLARATION_KEY,
            t.SHIPMENT_CHUNK_KEY,
            where=(t.BOOKING_NUMBER == int(self.reference))
        )
        cursor.execute(*query)
        rows = cursor.fetchall()
        logger.info(
            "FINTRADE_LOTS_START shipment=%s reference=%s bl=%s rows=%s",
            getattr(self, 'id', None), self.reference, self.bl_number,
            len(rows))
        inv_date = None
        inv_nb = None
        cleanup_needed = []
        skipped_rows = 0

        def normalize_quantity(value):
            return Decimal(str(value or 0)).quantize(Decimal('0.00001'))

        def lot_summary(lot):
            return {
                'id': lot.id,
                'name': getattr(lot, 'lot_name', None),
                'shipment_in': (
                    lot.lot_shipment_in.id
                    if getattr(lot, 'lot_shipment_in', None)
                    else None),
                'purchase_line': (
                    lot.line.id
                    if getattr(lot, 'line', None)
                    else None),
                'sale_line': (
                    lot.sale_line.id
                    if getattr(lot, 'sale_line', None)
                    else None),
            }

        def lqt_summary(lqt):
            return {
                'id': lqt.id,
                'lot_p': lqt.lot_p.id if lqt.lot_p else None,
                'lot_s': lqt.lot_s.id if lqt.lot_s else None,
                'quantity': lqt.lot_quantity,
                'unit': lqt.lot_unit.id if lqt.lot_unit else None,
                'shipment_in': (
                    lqt.lot_shipment_in.id
                    if lqt.lot_shipment_in else None),
                'shipment_origin': lqt.lot_shipment_origin,
                'status': lqt.lot_status,
                'availability': lqt.lot_av,
            }

        def add_cleanup(reason, declaration, chunk, lot_number, quantity,
                details=None):
            item = {
                'reason': reason,
                'shipment': getattr(self, 'id', None),
                'reference': self.reference,
                'bl': self.bl_number,
                'declaration': declaration,
                'chunk': chunk,
                'lot_number': lot_number,
                'quantity': quantity,
                'details': details or {},
            }
            cleanup_needed.append(item)
            logger.info("FINTRADE_CLEANUP_NEEDED %s", item)

        def select_or_link_lqt(lqts, quantity):
            quantity = normalize_quantity(quantity)
            current = [
                q for q in lqts
                if q.lot_p
                and q.lot_shipment_in
                and q.lot_shipment_in.id == self.id
                and normalize_quantity(q.lot_quantity) >= quantity
            ]
            if current:
                return current[0]
            unshipped = [
                q for q in lqts
                if q.lot_p
                and not q.lot_shipment_in
                and not q.lot_shipment_internal
                and not q.lot_shipment_out
                and normalize_quantity(q.lot_quantity) >= quantity
            ]
            if not unshipped:
                return None
            return LotQt.link_to_transport(
                unshipped[0], quantity,
                'stock.shipment.in,%s' % self.id)

        if rows:
            sale_line = None
            for row in rows:
                #Purchase & Sale creation
                LotQt = Pool().get('lot.qt')
                Lot = Pool().get('lot.lot')
                LotAdd = Pool().get('lot.add.line')
                Currency = Pool().get('currency.currency')
                Product = Pool().get('product.product')
                Party = Pool().get('party.party')
                Uom = Pool().get('product.uom')
                Sale = Pool().get('sale.sale')
                SaleLine = Pool().get('sale.line')
                dec_key = str(row[16]).strip()
                chunk_key = str(row[17]).strip()
                chunk_key_int = None
                if chunk_key and chunk_key.lower() not in {'none', 'null'}:
                    try:
                        chunk_key_int = int(chunk_key)
                    except (TypeError, ValueError):
                        logger.info(
                            "FINTRADE_INVALID_CHUNK_KEY shipment=%s "
                            "booking=%s declaration=%s chunk=%s",
                            getattr(self, 'id', None), self.reference,
                            dec_key, chunk_key)
                lot_unit = str(row[5]).strip().lower()
                product = str(row[6]).strip().upper()
                lot_net_weight = Decimal(row[4])
                lot_gross_weight = Decimal(row[3])
                lot_bales = Decimal(row[2])
                lot_number = row[1] 
                customer = str(row[7]).strip().upper()
                sell_price_currency = str(row[8]).strip().upper()
                sell_price_unit = str(row[9]).strip().lower()
                inv_date = str(row[13]).strip()
                inv_nb = str(row[11]).strip()
                sell_price = Decimal(row[10])
                premium = Decimal(row[14])
                reference = Decimal(row[15])
                logger.info(
                    "FINTRADE_LOT_ROW shipment=%s booking=%s lot_number=%s "
                    "chunk=%s declaration=%s net=%s gross=%s bales=%s "
                    "lot_unit=%s sell_currency=%s sell_unit=%s customer=%s "
                    "sale_contract=%s",
                    getattr(self, 'id', None), self.reference, lot_number,
                    chunk_key, dec_key, lot_net_weight, lot_gross_weight,
                    lot_bales, lot_unit, sell_price_currency,
                    sell_price_unit, customer, reference)
                if chunk_key_int is None:
                    skipped_rows += 1
                    add_cleanup(
                        'invalid_chunk_key', dec_key, chunk_key, lot_number,
                        lot_net_weight)
                    continue

                if chunk_key_int is not None:
                    existing_chunk_lots = Lot.search([
                        ('lot_chunk_key', '=', chunk_key_int),
                    ])
                    if existing_chunk_lots:
                        duplicate_details = {
                            'existing_count': len(existing_chunk_lots),
                            'existing_sample': [
                                lot_summary(lot)
                                for lot in existing_chunk_lots[:20]
                            ],
                            'existing_shipments': sorted({
                                lot.lot_shipment_in.id
                                for lot in existing_chunk_lots
                                if getattr(lot, 'lot_shipment_in', None)
                            }),
                        }
                        skipped_rows += 1
                        add_cleanup(
                            'chunk_already_has_physical_lots',
                            dec_key, chunk_key, lot_number,
                            lot_net_weight, duplicate_details)
                        logger.info(
                            "FINTRADE_SKIP_DUPLICATE_CHUNK shipment=%s "
                            "reference=%s chunk=%s existing_count=%s "
                            "existing_shipments=%s",
                            getattr(self, 'id', None), self.reference,
                            chunk_key, duplicate_details['existing_count'],
                            duplicate_details['existing_shipments'])
                        continue
                    logger.info(
                        "FINTRADE_CHUNK_CHECK chunk=%s existing_count=%s "
                        "existing_sample=%s",
                        chunk_key,
                        len(existing_chunk_lots),
                        [lot_summary(lot) for lot in existing_chunk_lots[:20]])
                declaration = SaleLine.search(['note','=',dec_key])
                logger.info(
                    "FINTRADE_DECLARATION_SEARCH declaration=%s count=%s "
                    "sale_line_ids=%s",
                    dec_key, len(declaration), [d.id for d in declaration])
                if declaration:
                    sale_line = declaration[0]
                    logger.info(
                        "FINTRADE_DECLARATION_REUSE declaration=%s "
                        "sale_line=%s sale=%s quantity=%s theoretical=%s "
                        "lot_ids=%s",
                        dec_key, sale_line.id,
                        sale_line.sale.id if sale_line.sale else None,
                        sale_line.quantity, sale_line.quantity_theorical,
                        [lot.id for lot in sale_line.lots])
                    if not sale_line.lots:
                        skipped_rows += 1
                        add_cleanup(
                            'declaration_without_virtual_lot',
                            dec_key, chunk_key, lot_number, lot_net_weight,
                            {'sale_line': sale_line.id})
                        continue
                    vlot = sale_line.lots[0]
                    lqt = LotQt.search([('lot_s','=',vlot.id)])
                    logger.info(
                        "FINTRADE_DECLARATION_LQT declaration=%s vlot=%s "
                        "vlot_qty=%s lqt_count=%s lqts=%s",
                        dec_key, vlot.id, vlot.lot_quantity, len(lqt),
                        [lqt_summary(q) for q in lqt])
                    lqt = select_or_link_lqt(lqt, lot_net_weight)
                    if not lqt:
                        skipped_rows += 1
                        add_cleanup(
                            'no_open_matched_lot_qt_for_declaration',
                            dec_key, chunk_key, lot_number, lot_net_weight,
                            {
                                'sale_line': sale_line.id,
                                'vlot': vlot.id,
                                'available_lqts': [
                                    lqt_summary(q)
                                    for q in LotQt.search([
                                        ('lot_s', '=', vlot.id),
                                        ('lot_p', '>', 0),
                                    ])
                                ],
                            })
                        continue
                    sale_line.quantity_theorical += round(lot_net_weight,2)
                    SaleLine.save([sale_line])
                else:
                    sale = Sale()
                    sale_line = SaleLine()
                    sale.party = Party.getPartyByName(customer,'CLIENT')
                    logger.info("SALE_PARTY:%s",sale.party)
                    sale.reference = reference
                    sale.from_location = self.from_location
                    sale.to_location = self.to_location
                    sale.company = 6
                    sale.payment_term = 2
                    if sale.party.addresses:
                        sale.invoice_address = sale.party.addresses[0]
                        sale.shipment_address = sale.party.addresses[0]
                    
                    if sell_price_currency == 'USC':
                        sale.currency = Currency.get_by_name('USD')
                        sale_line.enable_linked_currency = True
                        sale_line.linked_currency = 1
                        sale_line.linked_unit = Uom.get_by_name(sell_price_unit)
                        sale_line.linked_price = round(sell_price,4)
                    else:
                        sale.currency = Currency.get_by_name(sell_price_currency)
                        sale_line.unit_price = round(sell_price,4)
                        sale_line.unit = Uom.get_by_name(sell_price_unit)
                        sale_line.premium = premium
                    Sale.save([sale])
                    sale_line.sale = sale.id
                    sale_line.quantity = round(lot_net_weight,2)
                    sale_line.quantity_theorical = round(lot_net_weight,2)
                    sale_line.product = Product.get_by_name('BRAZIL COTTON')
                    logger.info("PRODUCT:%s",sale_line.product)
                    sale_line.unit = Uom.get_by_name(lot_unit)
                    if sell_price_currency == 'USC':
                        sale_line.unit_price = sale_line.get_price_linked_currency()
                    sale_line.price_type = 'priced'
                    sale_line.created_by_code = False
                    sale_line.note = dec_key
                    SaleLine.save([sale_line])

                    #need to link the virtual part to the shipment
                    lqt = LotQt.search([('lot_s','=',sale_line.lots[0])])
                    logger.info(
                        "FINTRADE_NEW_SALE_LQT sale=%s sale_line=%s "
                        "vlot=%s vlot_qty=%s lqt_count=%s lqts=%s",
                        sale.id, sale_line.id, sale_line.lots[0].id,
                        sale_line.lots[0].lot_quantity, len(lqt),
                        [
                            {
                                'id': q.id,
                                'lot_p': q.lot_p.id if q.lot_p else None,
                                'lot_s': q.lot_s.id if q.lot_s else None,
                                'quantity': q.lot_quantity,
                                'unit': q.lot_unit.id if q.lot_unit else None,
                                'shipment_in': (
                                    q.lot_shipment_in.id
                                    if q.lot_shipment_in else None),
                                'shipment_origin': q.lot_shipment_origin,
                                'status': q.lot_status,
                                'availability': q.lot_av,
                            }
                            for q in lqt
                        ])
                    if lqt:
                        lqt[0].lot_shipment_in = self
                        LotQt.save(lqt)
                        logger.info("SALE_LINKED_TO_SHIPMENT:%s",self)

                    ContractStart = Pool().get('contracts.start')
                    ContractDetail = Pool().get('contract.detail')
                    ct = ContractStart()
                    d = ContractDetail()
                    ct.type = 'Purchase'
                    ct.matched = True
                    ct.shipment_in = self
                    ct.lot = sale_line.lots[0]
                    ct.quantity = sale_line.quantity
                    ct.product = sale_line.product
                    ct.unit = sale_line.unit
                    d.party = Party.getPartyByName('FAIRCOT')
                    if sale_line.enable_linked_currency:
                        d.currency_unit = str(sale_line.linked_currency.id) + '_' + str(sale_line.linked_unit.id)
                    else:
                        d.currency_unit = str(sale.currency.id) + '_' + str(sale_line.unit.id)
                    d.quantity = sale_line.quantity
                    d.unit = sale_line.unit
                    d.price = sale_line.unit_price
                    d.price_type = 'priced'
                    d.crop = None
                    d.tol_min = 0
                    d.tol_max = 0
                    d.incoterm = None
                    d.reference = str(sale.id)
                    d.from_location = sale.from_location
                    d.to_location = sale.to_location
                    d.del_period = None
                    d.from_del = None
                    d.to_del = None
                    d.payment_term = sale.payment_term
                    ct.contracts = [d]
                    logger.info(
                        "FINTRADE_CREATE_PURCHASE_FROM_SALE shipment=%s "
                        "declaration=%s chunk=%s sale=%s sale_line=%s "
                        "source_lot=%s source_lot_qty=%s requested=%s "
                        "ct_quantity=%s active_ids=%s",
                        getattr(self, 'id', None), dec_key, chunk_key,
                        sale.id, sale_line.id, ct.lot.id,
                        ct.lot.lot_quantity, d.quantity,
                        getattr(ct, 'quantity', None),
                        Transaction().context.get('active_ids'))
                    ContractFactory.create_contracts(
                        ct.contracts,
                        type_=ct.type,
                        ct=ct,
                    )
            
                #Lots creation
                vlot = sale_line.lots[0]
                lqts = LotQt.search([
                    ('lot_s', '=', vlot.id),
                    ('lot_p', '>', 0),
                ])
                if not declaration:
                    lqt = select_or_link_lqt(lqts, lot_net_weight)
                logger.info(
                    "FINTRADE_PHYSICAL_LOT_SOURCE shipment=%s declaration=%s "
                    "chunk=%s vlot=%s vlot_qty=%s source_lqt_count=%s "
                    "source_lqts=%s",
                    getattr(self, 'id', None), dec_key, chunk_key, vlot.id,
                    vlot.lot_quantity, len(lqts),
                    [lqt_summary(q) for q in lqts])
                if lqt and normalize_quantity(lqt.lot_quantity) >= normalize_quantity(lot_net_weight):
                    l = LotAdd()
                    l.lot_qt = lot_bales
                    l.lot_unit = Uom.get_by_name('bale')
                    l.lot_unit_line = Uom.get_by_name(lot_unit)
                    l.lot_quantity = round(lot_net_weight,2)
                    l.lot_gross_quantity = round(lot_gross_weight,2)
                    l.lot_premium = premium
                    l.lot_chunk_key = int(chunk_key)
                    logger.info(
                        "FINTRADE_ADD_PHYSICAL_LOT shipment=%s "
                        "declaration=%s chunk=%s source_lqt=%s "
                        "source_open_before=%s add_net=%s add_gross=%s "
                        "add_bales=%s",
                        getattr(self, 'id', None), dec_key, chunk_key,
                        lqt.id, lqt.lot_quantity, l.lot_quantity,
                        l.lot_gross_quantity, l.lot_qt)
                    LotQt.add_physical_lots(lqt,[l])
                    logger.info(
                        "FINTRADE_ADD_PHYSICAL_LOT_DONE shipment=%s "
                        "declaration=%s chunk=%s source_lqt=%s",
                        getattr(self, 'id', None), dec_key, chunk_key,
                        lqt.id)
                else:
                    skipped_rows += 1
                    reason = 'no matched purchase lqt'
                    if lqt:
                        reason = 'matched purchase lqt open quantity too low'
                    add_cleanup(
                        reason, dec_key, chunk_key, lot_number, lot_net_weight,
                        {
                            'selected_lqt': lqt_summary(lqt) if lqt else None,
                            'source_lqts': [lqt_summary(q) for q in lqts],
                        })
                    logger.info(
                        "FINTRADE_SKIP_PHYSICAL_LOT shipment=%s "
                        "declaration=%s chunk=%s reason=%s vlot_qty=%s "
                        "source_lqt_count=%s",
                        getattr(self, 'id', None), dec_key, chunk_key,
                        reason, vlot.lot_quantity, len(lqts))

        if cleanup_needed:
            logger.info(
                "FINTRADE_CLEANUP_SUMMARY shipment=%s reference=%s "
                "skipped_rows=%s cleanup_count=%s cleanup_items=%s",
                getattr(self, 'id', None), self.reference, skipped_rows,
                len(cleanup_needed), cleanup_needed)

        return inv_date,inv_nb

    def html_to_text(self,html_content):
        text = re.sub(r"<br\s*/?>", "\n", html_content, flags=re.IGNORECASE)
        text = re.sub(r"</p\s*>", "\n\n", text, flags=re.IGNORECASE)
        text = re.sub(r"<[^>]+>", "", text)
        return html.unescape(text).strip()

    def create_service_order(self,so_payload):
        response = requests.post(
            "http://automation-service:8006/service-order",
            json=so_payload,
            timeout=10
        )
        response.raise_for_status()
        return response.json()
    
    @classmethod
    def _send_controller_email(cls, shipments, body_by_shipment=None):
        Attachment = Pool().get('ir.attachment')
        
        for sh in shipments:
            sh.result = "Email not sent"
            attachment = []
            if sh.add_bl:
                attachments = Attachment.search([
                    ('resource', '=', 'stock.shipment.in,' + str(sh.id)),
                ])
                if attachments:
                    content_b64 = base64.b64encode(attachments[0].data).decode('ascii')
                    attachment = [
                            {
                                "filename": attachments[0].name,
                                "content": content_b64,
                                "content_type": "application/pdf"
                            }
                        ]
            
            if sh.controller:
                Contact = Pool().get('party.contact_mechanism')
                contact = Contact.search(['party','=',sh.controller.id])
                if contact:
                    payload = {
                        "to": [contact[0].value],
                        "subject": "Request for control",
                        "body": (body_by_shipment or {}).get(
                            sh.id, sh.html_to_text(sh.instructions or '')),
                        "attachments": attachment,
                        "meta": {
                            "shipment": sh.bl_number,
                            "controller": sh.controller.id
                        }
                    }

                    response = requests.post(
                        "http://automation-service:8006/mail",
                        json=payload,
                        timeout=10
                    )

                    response.raise_for_status()
                    data = response.json()
                    logger.info("SEND_FROM_SHIPMENT:%s",data)
                    now = datetime.datetime.now()
                    sh.result = f"Email sent on {now.strftime('%d/%m/%Y %H:%M')}"
                    sh.save()

                    if sh.fees:
                        fee = sh.fees[0]
                        so_payload = {
                            "ControllerAlfCode": sh.controller.get_alf(),
                            "CurrKey": '3',
                            "Point1PlaceKey": sh.from_location.get_places(),
                            "Point2PlaceKey": sh.to_location.get_places(),
                            "OrderReference": sh.reference,
                            "FeeTotalCost": float(fee.amount),
                            "FeeUnitPrice": float(fee.price),
                            "ContractNumbers": sh.number,
                            "OrderQuantityGW": float(sh.get_quantity()) if sh.get_quantity() else float(1),
                            "NumberOfPackingBales": int(fee.quantity) if fee.quantity else int(1),
                            "ChunkKeyList": sh.get_chunk_key()
                        }

                        logger.info("PAYLOAD:%s",so_payload)
                        data = sh.create_service_order(so_payload)
                        logger.info("SO_NUMBER:%s",data.get('service_order_number'))
                        sh.result += f" / SO Nb {data.get('service_order_number')}"
                        sh.service_order_key = int(data.get('service_order_key'))
                        sh.save()

    @classmethod
    @ModelView.button_action('purchase_trade.act_send_controller_email')
    def send(cls, shipments):
        pass

    @classmethod
    @ModelView.button
    def compute(cls, shipments):
        Sof = Pool().get('sof.statement')       
        for sh in shipments:
            if sh.sof:
                for s in sh.sof:
                    laytime_start = s._applied_laytime_start()
                    if laytime_start:
                        s.laytime_commenced = laytime_start
                        quantity = float(s._calculation_quantity(
                            s.quantity or sh.get_quantity() or 0))
                        s.laytime_allowed = s._applied_laytime_allowed(quantity)
                        s.laytime_completed = s._applied_laytime_end()
                        #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}"
        #return URL(f"https://www.vesselfinder.com/en/vessels/VOS-TRAVELLER-IMO-{self.vessel.vessel_imo}", target='new')

    def get_imo(self,name):
        if self.vessel:
            return f"imo:{self.vessel.vessel_imo}"

    def get_sh(self, name):
        return self.id

    @classmethod
    def default_transport_type(cls):
        return 'vessel'

    @classmethod
    def default_cargo_mode(cls):
        return 'bulk'

    @classmethod
    def default_from_location(cls):
        location = Transaction().context.get(
            'lot_shipping_planned_from_location')
        if location:
            return location
        try:
            return super().default_from_location()
        except AttributeError:
            return None

    @classmethod
    def default_to_location(cls):
        location = Transaction().context.get(
            'lot_shipping_planned_to_location')
        if location:
            return location
        try:
            return super().default_to_location()
        except AttributeError:
            return None

    @fields.depends('transport_type', 'cargo_mode', 'vessel')
    def on_change_transport_type(self):
        if self.transport_type == 'truck':
            self.vessel = None
            self.cargo_mode = 'none'
        elif self.cargo_mode == 'none':
            self.cargo_mode = 'bulk'

    @classmethod
    def default_dashboard(cls):
        return 1

    def get_chunk_key(self):
        keys = [m.lot.lot_chunk_key for m in self.incoming_moves if m.lot]
        return ",".join(map(str, keys)) if keys else None

    def get_quantity(self,name=None):
        if self.incoming_moves:
            return sum([(e.quantity if e.quantity else 0) for e in self.incoming_moves])
        if self.lotqt:
            return sum([(e.lot_quantity if e.lot_quantity else 0) for e in self.lotqt])

    def _pnl_lot_ids(self):
        lot_ids = []
        seen = set()

        def add(lot):
            lot_id = getattr(lot, 'id', lot)
            if not lot_id or lot_id in seen:
                return
            seen.add(lot_id)
            lot_ids.append(lot_id)

        for move in self.incoming_moves or []:
            add(getattr(move, 'lot', None))
        for lotqt in self.lotqt or []:
            add(getattr(lotqt, 'lot_p', None))
            add(getattr(lotqt, 'lot_s', None))
        return lot_ids

    def get_pnl_lines(self, name=None):
        if not self.id:
            return []
        ValuationLine = Pool().get('valuation.valuation.line')
        lines = ValuationLine.search(
            [('shipment_in', '=', self.id)],
            order=[('date', 'DESC'), ('id', 'DESC')])
        return [line.id for line in lines]

    @staticmethod
    def _unique_condition_ids(conditions):
        ids = []
        seen = set()
        for condition in conditions or []:
            condition_id = getattr(condition, 'id', condition)
            if not condition_id or condition_id in seen:
                continue
            seen.add(condition_id)
            ids.append(condition_id)
        return ids

    def get_owner_charter_conditions(self, name=None):
        return self._unique_condition_ids(
            getattr(self.charter_party, 'conditions', None) or [])

    def _get_purchase_lines_from_lots(self):
        lines = []
        seen = set()
        for lot_qt in getattr(self, 'lotqt', []) or []:
            line = getattr(getattr(lot_qt, 'lot_p', None), 'line', None)
            line_id = getattr(line, 'id', None)
            if not line or line_id in seen:
                continue
            seen.add(line_id)
            lines.append(line)
        return lines

    @staticmethod
    def _list_rec_names(records):
        names = []
        seen = set()
        for record in records:
            name = (
                getattr(record, 'rec_name', None)
                or getattr(record, 'name', None)
                or getattr(record, 'vessel_name', None))
            if not name or name in seen:
                continue
            seen.add(name)
            names.append(name)
        return ', '.join(names)

    def get_list_from_locations(self, name=None):
        locations = []
        for lot_qt in getattr(self, 'lotqt', []) or []:
            location = (
                getattr(lot_qt, 'planned_from_location', None)
                or getattr(self, 'from_location', None))
            if location:
                locations.append(location)
        if not locations and getattr(self, 'from_location', None):
            locations.append(self.from_location)
        return self._list_rec_names(locations)

    def get_list_to_locations(self, name=None):
        locations = []
        for lot_qt in getattr(self, 'lotqt', []) or []:
            location = (
                getattr(lot_qt, 'planned_to_location', None)
                or getattr(self, 'to_location', None))
            if location:
                locations.append(location)
        if not locations and getattr(self, 'to_location', None):
            locations.append(self.to_location)
        return self._list_rec_names(locations)

    def get_list_purchase_suppliers(self, name=None):
        suppliers = []
        for line in self._get_purchase_lines_from_lots():
            purchase = getattr(line, 'purchase', None)
            supplier = getattr(purchase, 'party', None)
            if supplier:
                suppliers.append(supplier)
        return self._list_rec_names(suppliers)

    def _get_sale_lines_from_lots(self):
        lines = []
        seen = set()
        for lot_qt in getattr(self, 'lotqt', []) or []:
            line = getattr(getattr(lot_qt, 'lot_s', None), 'sale_line', None)
            line_id = getattr(line, 'id', None)
            if not line or line_id in seen:
                continue
            seen.add(line_id)
            lines.append(line)
        return lines

    def get_purchase_charter_conditions(self, name=None):
        conditions = []
        for line in self._get_purchase_lines_from_lots():
            getter = getattr(line, 'get_effective_charter_conditions', None)
            if getter:
                conditions.extend(getter())
        return self._unique_condition_ids(conditions)

    def get_sale_charter_conditions(self, name=None):
        conditions = []
        for line in self._get_sale_lines_from_lots():
            getter = getattr(line, 'get_effective_charter_conditions', None)
            if getter:
                conditions.extend(getter())
        return self._unique_condition_ids(conditions)
    
    def get_bales(self,name=None):
        Lot = Pool().get('lot.lot')
        lots = Lot.search(['lot_shipment_in','=',self.id])
        if lots:
            return sum([l.lot_qt for l in lots])

    def get_unit(self,name=None):
        if self.incoming_moves:
            return self.incoming_moves[0].unit

    def get_info(self,name):
        if self.vessel:
            vessel = Pool().get('trade.vessel')(self.vessel)
            return vessel.get_info()

    def get_anim(self,name):
        gif_url = "http://vps107.geneva.hosting:8000/images/tanker.gif"
        anim = f'<br><img src="{gif_url}" alt="loading..." style="margin-top:10px; width:80px;">'
        return anim
        
    def extract_sof_data_and_events_from_json(self, sof_json):
        def parse_datetime(date_str, time_str):
            dt = datetime.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
            return dt 
    
        def find_first_event(keywords):
            for item in sof_json:
                if any(kw.lower() in item['event'].lower() for kw in keywords):
                    return parse_datetime(item['date'], item['time']) - datetime.timedelta(hours=1)
            return None

        def find_last_event(keywords):
            for item in reversed(sof_json):
                if any(kw.lower() in item['event'].lower() for kw in keywords):
                    return parse_datetime(item['date'], item['time'])
            return None

        events = []
        for item in sof_json:
            try:
                start = parse_datetime(item['date'], item['time'])
                end_str = item.get("end_time")
                end = datetime.datetime.strptime(end_str, "%Y-%m-%d %H:%M") if end_str else None

                description = item["event"]
                duration = (end - start).total_seconds() / 3600 if end else 0
                deductible = any(word in description.lower() for word in ['rain', 'waiting', 'no loading'])

                events.append({
                    'start': start,
                    'start_date': start.date(),
                    'start_time': start.time(),
                    'end': end,
                    'end_date': end.date() if end else None,
                    'end_time': end.time() if end else None,
                    'duration': duration,
                    'description': description,
                    'deductible': deductible,
                })
            except Exception as e:
                logger.error("Error parsing SOF event: %s | %s", item, e)

        # Extraction par mots-clés (inchangé)
        arrival = find_first_event(["arrived", "anchor at", "proceed to anchorage"])
        nor = find_first_event(["nor tendered", "nor accepted"])
        start_pump = find_first_event(["start pumping", "commenced loading"])
        end_pump = find_first_event(["end pumping", "completed loading"])
        hoses_connected = find_first_event(["hose connected"])
        hoses_disconnected = find_first_event(["hose off", "hose disconnected"])
        sailing_time = find_first_event(["sailing", "sb for sailing", "sea pilot on board"])

        return {
            'arrival_time': arrival,
            'notice_of_readiness_time': nor,
            'start_pumping': start_pump,
            'end_pumping': end_pump,
            'hoses_connected': hoses_connected,
            'hoses_disconnected': hoses_disconnected,
            'sailing_time': sailing_time,
            'events': events,
        }

    @classmethod
    def validate(cls, shipments):
        super(ShipmentIn, cls).validate(shipments)
        Lot = Pool().get('lot.lot')
        StockMove = Pool().get('stock.move')
        Sof = Pool().get('sof.statement')
        SoFEvent = Pool().get("sof.event")
        for sh in shipments:
            lots = Lot.search(['lot_shipment_in','=',sh.id])
            if not lots:
                if sh.lotqt:
                    lots = [sh.lotqt[0].lot_p]
            if lots:
                if sh.state == 'received':
                    for lot in lots:
                        if lot.lot_type == 'physic':
                            if sh.to_location.type == 'storage':
                                lot.lot_status = 'stock'
                            elif sh.to_location.type == 'supplier':
                                lot.lot_status = 'destination'
                            elif sh.to_location.type == 'customer':
                                lot.lot_status = 'delivered'
                            lot.lot_shipment_in = None
                            lot.lot_av = 'available'
                            Lot.save([lot])
                elif sh.incoming_moves:
                    for m in sh.incoming_moves:
                        if sh.bl_date:
                            m.bldate = sh.bl_date
                        if sh.bl_number:
                            m.blnumber = sh.bl_number
                        if sh.bl_date or sh.bl_number:
                            StockMove.save([m])
                        for lot in lots:
                            if lot.lot_type == 'physic': 
                                lot.lot_status = 'transit'
                                Lot.save([lot])
                #update line valuation
                Pnl = Pool().get('valuation.valuation')
                for lot in lots:
                    Pnl.generate(lot.line if lot.line else lot.sale_line)
            if sh.sof:
                for sof in sh.sof:
                    if sof.chart:
                        sof.laytime_type = 'nor'
                        sof.laytime_clause_hour = datetime.time(6,0)
                        sof.demurrage_rate = 26000
                        sof.pumping_rate = 350
                        sof.laytime_type_o = 'nor'
                        sof.laytime_clause_hour_o = datetime.time(6,0)
                        sof.demurrage_rate_o = 22000
                        sof.pumping_rate_o = 300
                        sof.timebar_day = 60
                        sof.timebar_warn = 0
                        Sof.save([sof])
                    if sof.sof:
                        extracted_sof = sh.extract_sof_data_and_events_from_json(sof_json)
                        sof.arrival_time = extracted_sof['arrival_time']
                        sof.notice_of_readiness_time = extracted_sof['notice_of_readiness_time']
                        sof.start_pumping = extracted_sof['start_pumping']
                        sof.end_pumping = extracted_sof['end_pumping']
                        sof.hoses_connected = extracted_sof['hoses_connected']
                        sof.hoses_disconnected = extracted_sof['hoses_disconnected']
                        sof.sailing_time = extracted_sof['sailing_time']
                        Sof.save([sof])
                        for e in extracted_sof["events"]:
                            SoFEvent.create(
                                [
                                    {
                                        "statement": sof.id,
                                        "start": e["start"],
                                        "start_date": e["start_date"],
                                        "start_time": e["start_time"],
                                        "end": e["end"],
                                        "end_date": e["end_date"],
                                        "end_time": e["end_time"],
                                        "duration": e["duration"],
                                        "description": e["description"],
                                        "deductible": e["deductible"],
                                    }
                                ]
                            )

class ShipmentControllerEmailStart(ModelView):
    "Send Controller Email"
    __name__ = 'stock.shipment.in.send.controller.email.start'

    body = fields.Text("Message", required=True)


class ShipmentControllerEmail(Wizard):
    "Send Controller Email"
    __name__ = 'stock.shipment.in.send.controller.email'

    start = StateView(
        'stock.shipment.in.send.controller.email.start',
        'purchase_trade.shipment_controller_email_start_view_form',
        [
            Button("Cancel", 'end', 'tryton-cancel'),
            Button("Send", 'send_', 'tryton-ok', default=True),
        ])
    send_ = StateTransition()

    def _shipment(self):
        Shipment = Pool().get('stock.shipment.in')
        active_ids = Transaction().context.get('active_ids') or []
        if len(active_ids) != 1:
            raise UserError("Select one shipment.")
        return Shipment(active_ids[0])

    def default_start(self, fields):
        shipment = self._shipment()
        return {
            'body': shipment.html_to_text(shipment.instructions or ''),
        }

    def transition_send_(self):
        Shipment = Pool().get('stock.shipment.in')
        shipment = self._shipment()
        Shipment._send_controller_email(
            [shipment], {shipment.id: self.start.body or ''})
        return 'end'

    def end(self):
        return 'reload'


class FindVessel(Wizard):
    __name__ = 'stock.shipment.in.vf'
    start_state = 'vf'
    vf = StateAction('purchase_trade.url_vessel_finder')

    def do_vf(self, action):
        action['url'] = self.record.get_vessel_url()
        return action, {}


class ShipmentOut(metaclass=PoolMeta):
    __name__ = 'stock.shipment.out'

    from_location = fields.Many2One('stock.location', 'From location')
    to_location = fields.Many2One('stock.location', 'To location')
    transport_type = fields.Selection([
        ('vessel', 'Vessel'),
        ('truck', 'Truck'),
        ('other', 'Other'),
    ], 'Transport type')
    vessel = fields.Many2One('trade.vessel',"Vessel")
    info = fields.Function(fields.Text("Info"),'get_info')
    fees = fields.One2Many('fee.fee','shipment_in',"Fees")
    lotqt = fields.One2Many('lot.qt','lot_shipment_in',"Lots")
    quantity = fields.Function(fields.Numeric("Quantity"),'get_quantity')
    unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit')
    etl = fields.Date("Est. Loading")
    eta = fields.Date("Est. Arrival")
    etd = fields.Date("Est. Discharge")
    unloaded = fields.Date("Unloaded")
    booking = fields.Char("Booking Nb")
    ref = fields.Char("Reference")
    note = fields.Text("Notes")

    @classmethod
    def default_transport_type(cls):
        return 'vessel'

    def get_quantity(self,name=None):
        if self.incoming_moves:
            return sum([(e.quantity if e.quantity else 0) for e in self.incoming_moves])

    def get_unit(self,name=None):
        if self.incoming_moves:
            return self.incoming_moves[0].unit

    def get_info(self,name):
        if self.vessel:
            vessel = Pool().get('trade.vessel')(self.vessel)
            return vessel.get_info()

from trytond.model import ModelSQL, ModelView, fields
from trytond.pool import PoolMeta, Pool
from trytond.wizard import Wizard, StateView, StateAction, StateTransition, Button
from trytond.transaction import Transaction
from trytond.report import Report
from trytond.i18n import gettext
from trytond.pyson import Eval

import datetime
import io
import re
import PyPDF2

class StatementOfFacts(ModelSQL, ModelView):
    "Statement of Facts for Vessel Discharge"
    __name__ = 'sof.statement'

    _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',
        'completed_loading': 'Completed Loading',
        'completed_discharge': 'Completed Discharge',
        'hoses_disconnected': 'Hoses Disconnected',
        'documents_on_board': 'Documents on Board',
        'sailing': 'Sailing',
    }
    _party_role_labels = {
        'owner': 'Owner',
        'charterer': 'Charterer',
        'supplier': 'Supplier',
        'customer': 'Customer',
        'broker': 'Broker',
        'agent': 'Agent',
        'terminal': 'Terminal',
        'other': 'Other',
    }
    _responsibility_labels = {
        'ours': 'Ours',
        'counterparty': 'Counterparty',
        'shared': 'Shared',
        'pass_through': 'Pass-through',
        'owner': 'Owner',
        'charterer': 'Charterer',
    }

    _datetime_parts = {
        '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'),
        'completed_loading': (
            'completed_loading_date', 'completed_loading_hour'),
        'completed_discharge': (
            'completed_discharge_date', 'completed_discharge_hour'),
        'hoses_disconnected': (
            'hoses_disconnected_date', 'hoses_disconnected_hour'),
        'documents_on_board': (
            'documents_on_board_date', 'documents_on_board_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')
    loading_sequence = fields.Char('Loading Sequence')  # ex: "Lot A - Supplier X"

    # === Termes contractuels ===
    laytime_allowed = fields.Float('Laytime Allowed (hours)')
    notice_of_readiness_time = fields.DateTime('Notice of Readiness Time')

    laytime_type = fields.Selection([
        ('nor', 'NOR +'),
        (None, 'Laytime start'),
    ], 'Laytime clause')
    laytime_clause_hour = fields.Time("")
    laytime_start = fields.DateTime('Laytime Start')
    laytime_end = fields.DateTime('Laytime End')
    demurrage_rate = fields.Numeric('Demurrage Rate ($/day)', digits=(16, 2))
    pumping_rate = fields.Float('Pumping Rate (MT/hour)')
    demurrage_rate_override = fields.Numeric(
        'Demurrage Rate Override ($/day)', digits=(16, 2))
    despatch_rate_override = fields.Numeric(
        'Despatch Rate Override ($/day)', digits=(16, 2))
    pumping_rate_override = fields.Numeric(
        'Pumping Rate Override (MT/hour)', digits=(16, 4))
    additional_quantity = fields.Numeric('Additional Qt', digits=(16, 4))

    laytime_type_o = fields.Selection([
        ('nor', 'NOR +'),
        (None, 'Laytime start'),
    ], 'Laytime clause')
    laytime_clause_hour_o = fields.Time("")
    laytime_start_o = fields.DateTime('Laytime Start')
    laytime_end_o = fields.DateTime('Laytime End')
    demurrage_rate_o = fields.Numeric('Demurrage Rate ($/day)', digits=(16, 2))
    pumping_rate_o = fields.Float('Pumping Rate (MT/hour)')

    timebar_day = fields.Integer("TimeBar")
    timebar_warn = fields.Integer("Warning TimeBar")
    charter_party = fields.Many2One('stock.charter.party', "Charter Party")
    applied_conditions = fields.Function(
        fields.One2Many('charter.condition', '', "Available Conditions"),
        'on_change_with_applied_conditions', setter='set_applied_conditions')
    applied_condition = fields.Many2One(
        'charter.condition', "Applied Condition",
        domain=[('id', 'in', Eval('applied_conditions', []))],
        depends=['applied_conditions'])
    owner_charter_conditions = fields.Function(
        fields.One2Many('charter.condition', '', "Owner Conditions"),
        'get_owner_charter_conditions')
    purchase_charter_conditions = fields.Function(
        fields.One2Many('charter.condition', '', "Supplier Conditions"),
        'get_purchase_charter_conditions')
    sale_charter_conditions = fields.Function(
        fields.One2Many('charter.condition', '', "Customer Conditions"),
        'get_sale_charter_conditions')
    chart = fields.Many2One('document.incoming',"Charter Party Terms")
    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')
    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')
    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')
    completed_loading = fields.DateTime('Completed Loading')
    completed_loading_date = fields.Date('Completed Loading Date')
    completed_loading_hour = fields.Time('Completed Loading Time')
    completed_discharge = fields.DateTime('Completed Discharge')
    completed_discharge_date = fields.Date('Completed Discharge Date')
    completed_discharge_hour = fields.Time('Completed Discharge Time')
    hoses_disconnected = fields.DateTime('Hoses Disconnected')
    hoses_disconnected_date = fields.Date('Hoses Disconnected Date')
    hoses_disconnected_hour = fields.Time('Hoses Disconnected Time')
    documents_on_board = fields.DateTime('Documents on Board')
    documents_on_board_date = fields.Date('Documents on Board Date')
    documents_on_board_hour = fields.Time('Documents on Board 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)
    compensation_type = fields.Selection([
        ('demurrage', 'Demurrage'),
        ('despatch', 'Despatch'),
        (None, 'No Compensation'),
    ], 'Compensation Type')
    compensation_amount = fields.Numeric('Compensation Amount ($)', readonly=True, digits=(16, 2))
    quantity = fields.Function(fields.Float("Quantity loaded"),'get_qt')
    calculation_condition = fields.Function(
        fields.Char("Condition Used"), 'get_calculation_info')
    calculation_party_role = fields.Function(
        fields.Char("Party Role"), 'get_calculation_info')
    calculation_responsibility = fields.Function(
        fields.Char("Responsibility"), 'get_calculation_info')
    calculation_laytime_allowed = fields.Function(
        fields.Char("Allowed Laytime Source"), 'get_calculation_info')
    calculation_pumping_rate = fields.Function(
        fields.Char("Pumping Rate Source"), 'get_calculation_info')
    calculation_demurrage_rate = fields.Function(
        fields.Char("Demurrage Rate Source"), 'get_calculation_info')
    calculation_despatch_rate = fields.Function(
        fields.Char("Despatch Rate Source"), 'get_calculation_info')
    calculation_start_event = fields.Function(
        fields.Char("Start Event"), 'get_calculation_info')
    calculation_start_event_time = fields.Function(
        fields.Char("Start Event Time"), 'get_calculation_info')
    calculation_start_offset = fields.Function(
        fields.Char("Start Offset"), 'get_calculation_info')
    calculation_end_event = fields.Function(
        fields.Char("End Event"), 'get_calculation_info')
    calculation_end_event_time = fields.Function(
        fields.Char("End Event Time"), 'get_calculation_info')
    calculation_rate_used = fields.Function(
        fields.Char("Rate Used"), 'get_calculation_info')

    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):
        self._set_default_applied_condition()

    @fields.depends('shipment', 'charter_party', 'applied_condition')
    def on_change_charter_party(self):
        self._set_default_applied_condition()

    def _set_default_applied_condition(self):
        condition_ids = self.on_change_with_applied_conditions()
        applied_condition_id = getattr(self.applied_condition, 'id', None)
        if applied_condition_id in condition_ids:
            return
        self.applied_condition = None
        if len(condition_ids) == 1:
            self.applied_condition = condition_ids[0]

    def get_owner_charter_conditions(self, name=None):
        if self.shipment:
            return self.shipment.get_owner_charter_conditions(name)
        if self.charter_party:
            return ShipmentIn._unique_condition_ids(self.charter_party.conditions)
        return []

    def get_purchase_charter_conditions(self, name=None):
        if self.shipment:
            return self.shipment.get_purchase_charter_conditions(name)
        return []

    def get_sale_charter_conditions(self, name=None):
        if self.shipment:
            return self.shipment.get_sale_charter_conditions(name)
        return []

    @fields.depends('shipment', 'charter_party')
    def on_change_with_applied_conditions(self, name=None):
        return ShipmentIn._unique_condition_ids(
            self.get_owner_charter_conditions(name)
            + self.get_purchase_charter_conditions(name)
            + self.get_sale_charter_conditions(name))

    def get_applied_conditions(self, name=None):
        return self.on_change_with_applied_conditions(name)

    @classmethod
    def set_applied_conditions(cls, statements, name, value):
        pass

    @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 _rate_override(self, category):
        override_fields = {
            'demurrage': 'demurrage_rate_override',
            'despatch': 'despatch_rate_override',
            'pumping': 'pumping_rate_override',
        }
        value = getattr(self, override_fields.get(category, ''), None)
        if value is not None:
            return Decimal(str(value))

    def _applied_rate(self, category):
        override = self._rate_override(category)
        if override is not None:
            return override
        rate = self._condition_rate(category)
        if rate is not None:
            return rate
        if category == 'demurrage':
            legacy = self.demurrage_rate_o or self.demurrage_rate
            if legacy is not None:
                return Decimal(str(legacy))
        if category == 'pumping':
            legacy = self.pumping_rate_o or self.pumping_rate
            if legacy:
                return Decimal(str(legacy))

    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._applied_rate('pumping')
        if pumping_rate:
            return round(float(Decimal(str(quantity or 0)) / pumping_rate), 2)

    def _applied_turn_delta(self):
        condition = getattr(self, 'applied_condition', None)
        if condition:
            value = getattr(condition, 'laytime_start_offset', None)
            unit = getattr(condition, 'laytime_start_offset_unit', None)
            if value is None:
                value = getattr(condition, 'turn_time', None)
                unit = getattr(condition, 'turn_time_unit', None)
            if value is not None:
                return self._offset_delta(value, unit)

        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)

    @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,
            'completed_loading': self.completed_loading,
            'completed_discharge': self.completed_discharge,
            'hoses_disconnected': self.hoses_disconnected,
            'documents_on_board': self.documents_on_board,
            '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 not in {'earliest_of', 'latest_of'}:
            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:
            return base
        return self.notice_of_readiness_time

    def _applied_laytime_start(self):
        base = self._laytime_start_base()
        if not base:
            return
        condition = getattr(self, 'applied_condition', None)
        rule = getattr(condition, 'laytime_start_rule', 'simple')
        if rule in {'earliest_of', 'latest_of'}:
            return base
        return base + self._applied_turn_delta()

    def _applied_laytime_end(self):
        condition = getattr(self, 'applied_condition', None)
        event = getattr(condition, 'laytime_end_event', None)
        end = self._condition_event_datetime(event)
        if end:
            return end
        return self.hoses_disconnected or self.end_pumping or self.sailing_time

    def _applied_compensation_amount(self):
        balance = Decimal(str(self.laytime_balance or 0))
        if balance < 0:
            self.compensation_type = 'demurrage'
            rate = self._applied_rate('demurrage') or Decimal(0)
            return round(balance * rate / Decimal(24), 2)
        if balance > 0:
            rate = self._applied_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)

    @staticmethod
    def _format_calculation_datetime(value):
        if value:
            return value.strftime('%d/%m/%Y %H:%M')
        return ''

    @staticmethod
    def _format_calculation_number(value):
        if value is None:
            return ''
        decimal = Decimal(str(value)).normalize()
        return format(decimal, 'f')

    def _calculation_offset_text(self):
        condition = getattr(self, 'applied_condition', None)
        rule = getattr(condition, 'laytime_start_rule', 'simple')
        if rule 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:
            value = getattr(condition, 'turn_time', None)
            unit = getattr(condition, 'turn_time_unit', None)
        if value is not None:
            return '%s %s' % (
                self._format_calculation_number(value),
                unit or 'hours')
        legacy = self.laytime_clause_hour or self.laytime_clause_hour_o
        if legacy:
            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._applied_rate(category)
        if rate is not None:
            return '%s / day' % self._format_calculation_number(rate)
        return ''

    def _calculation_pumping_rate_text(self):
        rate = self._applied_rate('pumping')
        if rate is not None:
            return '%s MT/hour' % self._format_calculation_number(rate)
        return ''

    def _calculation_laytime_allowed_text(self):
        condition = getattr(self, 'applied_condition', None)
        if condition and condition.laytime_allowed is not None:
            value = self._format_calculation_number(condition.laytime_allowed)
            return '%s %s' % (value, condition.laytime_unit or 'hours')
        pumping_rate = self._calculation_pumping_rate_text()
        if pumping_rate:
            return 'Computed from pumping rate'
        return ''

    def _calculation_rate_used_text(self):
        balance = Decimal(str(self.laytime_balance or 0))
        if balance < 0:
            rate = self._calculation_rate_text('demurrage')
            if rate:
                return 'Demurrage: %s' % rate
        if balance > 0:
            rate = self._calculation_rate_text('despatch')
            if rate:
                return 'Despatch: %s' % rate
        return ''

    def get_calculation_info(self, name):
        condition = getattr(self, 'applied_condition', None)
        start_event = getattr(condition, 'laytime_start_event', None)
        end_event = getattr(condition, 'laytime_end_event', None)
        if name == 'calculation_condition':
            return getattr(condition, 'rec_name', None) or getattr(
                condition, 'name', None) or ''
        if name == 'calculation_party_role':
            return self._party_role_labels.get(
                getattr(condition, 'party_role', None), '')
        if name == 'calculation_responsibility':
            return self._responsibility_labels.get(
                getattr(condition, 'responsibility', None), '')
        if name == 'calculation_laytime_allowed':
            return self._calculation_laytime_allowed_text()
        if name == 'calculation_pumping_rate':
            return self._calculation_pumping_rate_text()
        if name == 'calculation_demurrage_rate':
            return self._calculation_rate_text('demurrage')
        if name == 'calculation_despatch_rate':
            return self._calculation_rate_text('despatch')
        if name == 'calculation_start_event':
            return self._calculation_start_rule_text()
        if name == 'calculation_start_event_time':
            return self._format_calculation_datetime(
                self._laytime_start_base())
        if name == 'calculation_start_offset':
            return self._calculation_offset_text()
        if name == 'calculation_end_event':
            return self._event_labels.get(
                end_event, self._event_labels['hoses_disconnected'])
        if name == 'calculation_end_event_time':
            return self._format_calculation_datetime(
                self._applied_laytime_end())
        if name == 'calculation_rate_used':
            return self._calculation_rate_used_text()
        return ''

    def get_qt(self,name):
        if self.shipment:
            return self.shipment.get_quantity()

    def _calculation_quantity(self, base_quantity=None):
        if base_quantity is None:
            base_quantity = self.quantity
        return Decimal(str(base_quantity or 0)) + Decimal(str(
            self.additional_quantity or 0))

class SoFEvent(ModelSQL, ModelView):
    "Event from Statement of Facts"
    __name__ = 'sof.event'

    statement = fields.Many2One('sof.statement', "Statement", ondelete='CASCADE')

    start = fields.DateTime("Start Time")
    end = fields.DateTime("End Time")

    start_date = fields.Date("Start Date")
    start_time = fields.Time("Start Time (H)")
    end_date = fields.Date("End Date")
    end_time = fields.Time("End Time (H)")

    duration = fields.Float("Duration")
    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"""
    __name__ = 'import.s'

    pdf = fields.Binary('Statement of Facts PDF', required=True, filename='filename')
    filename = fields.Char('SoF Filename',readonly=True)
    charter = fields.Binary('Charter Party File', required=True, filename='charter_name')
    charter_name = fields.Char('Charter Filename',readonly=True)

class ImportSoFWizard(Wizard):
    "Import Statement of Facts Wizard"
    __name__ = 'sof.import'

    start = StateTransition()

    sof = StateView('import.s', 'purchase_trade.view_sof_import_start', [
        Button('Cancel', 'end', 'tryton-cancel'),
        Button('Import', 'processing', 'tryton-ok')
    ])

    processing = StateTransition()

    def transition_start(self):
        return 'sof'
        
    def parse_pdf_text(self, binary_data):
        text = ""
        with io.BytesIO(binary_data) as pdf_file:
            reader = PyPDF2.PdfReader(pdf_file)
            for page in reader.pages:
                text += page.extract_text()
        return text

    def parse_charter_text(self, binary_data):
        try:
            return binary_data.decode('utf-8')
        except Exception:
            return ''

    def extract_fields_from_text(self,text):
        def extract(pattern):
            match = re.search(pattern, text)
            return match.group(1).strip() if match else None

        vessel = extract(r"Vessel Name:\s*(.+)")
        port = extract(r"Port:\s*(.+)")
        berth = extract(r"Berth:\s*(.+)")
        cargo = extract(r"Cargo:\s*(.+)")
        charter_ref = extract(r"Charter Party Ref:\s*(.+)")
        laytime_allowed = extract(r"Laytime Allowed:\s*(\d+)\s*hours")
        demurrage = extract(r"Demurrage Rate:\s*\$(\d[\d,]*)/day")
        despatch = extract(r"Despatch Rate:\s*\$(\d[\d,]*)/day")
        laytime_start_raw = extract(r"Laytime Start:\s*([\d\-: ]+)")
        laytime_end_raw = extract(r"Laytime End:\s*([\d\-: ]+)")
        quantity_raw = extract(r"Quantity:\s*([\d,]+)\s*MT")
        quantity = float(quantity_raw.replace(',', '')) if quantity_raw else None

        laytime_start = datetime.datetime.strptime(laytime_start_raw, "%Y-%m-%d %H:%M").date() if laytime_start_raw else None
        laytime_end = datetime.datetime.strptime(laytime_end_raw, "%Y-%m-%d %H:%M").date() if laytime_end_raw else None

        return {
            'vessel_name': vessel,
            'port': port,
            'berth': berth,
            'cargo': cargo,
            'quantity': quantity,
            'charter_ref': charter_ref,
            'laytime_allowed': float(laytime_allowed) if laytime_allowed else None,
            'laytime_start': laytime_start,
            'laytime_end': laytime_end,
            'demurrage_rate': float(demurrage.replace(',', '')) if demurrage else None,
            'despatch_rate': float(despatch.replace(',', '')) if despatch else None,
        }

    def extract_sof_events(self, text):
        """
        Extrait les événements datés à partir du texte brut du SOF PDF.
        Format attendu (comme dans ton fichier) :
        '15.05.2024 13:00 - 15.05.2024 18:00 Loading stopped due to rain'
        """
        pattern = re.compile(
            r'(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2})\s*-\s*(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2})\s+(.+)',
            re.MULTILINE
        )
        events = []
        logger.info("TEXT FOR EVENTS:\n%s", text)
        matchs = pattern.finditer(text)
        logger.info("MATCHS:%s",matchs)
        for match in matchs:
            logger.debug("MATCH: %s | %s -> %s", match.group(5), match.group(1), match.group(3))
            start = datetime.datetime.strptime(f"{match.group(1)} {match.group(2)}", "%d.%m.%Y %H:%M")
            end = datetime.datetime.strptime(f"{match.group(3)} {match.group(4)}", "%d.%m.%Y %H:%M")
            description = match.group(5).strip()
            duration = (end - start).total_seconds() / 3600
            deductible = any(word in description.lower() for word in ['rain', 'waiting', 'no loading'])
            events.append({
                'start': start,
                'end': end,
                'duration': duration,
                'description': description,
                'deductible': deductible,
            })
        return events

    def extract_sof_data_and_events(self, text):
        def extract(pattern, fmt=None, default=None):
            match = re.search(pattern, text, re.IGNORECASE)
            if not match:
                logger.warning("Pattern not found: %s", pattern)
                return default
            raw = match.group(1).strip()
            try:
                return datetime.datetime.strptime(raw, fmt) if fmt else raw
            except Exception:
                logger.error("Failed to parse date: %s", raw)
                return default

        # Nouvelles regex plus souples
        arrival = extract(r"vessel.*arrived.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")
        nor = extract(r"nor.*tendered.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")
        start_pump = extract(r"start.*pumping.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")
        end_pump = extract(r"end.*pumping.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")

        logger.info("ARRIVAL: %s", arrival)
        logger.info("NOR: %s", nor)
        logger.info("START_PUMP: %s", start_pump)
        logger.info("END_PUMP: %s", end_pump)

        events = self.extract_sof_events(text)

        return {
            'arrival_time': arrival,
            'notice_of_readiness_time': nor,
            'start_pumping': start_pump,
            'end_pumping': end_pump,
            'hoses_connected': None,
            'hoses_disconnected': None,
            'sailing_time': None,
            'events': events,
        }

    def extract_sof_data_and_events_from_json(self, sof_json):
        def parse_datetime(date_str, time_str):
            return datetime.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")

        def find_first_event(keywords):
            for item in sof_json:
                if any(kw.lower() in item['event'].lower() for kw in keywords):
                    return parse_datetime(item['date'], item['time'])
            return None

        def find_last_event(keywords):
            for item in reversed(sof_json):
                if any(kw.lower() in item['event'].lower() for kw in keywords):
                    return parse_datetime(item['date'], item['time'])
            return None

        events = []
        for item in sof_json:
            try:
                start = parse_datetime(item['date'], item['time'])
                end_str = item.get("end_time")
                end = datetime.datetime.strptime(end_str, "%Y-%m-%d %H:%M") if end_str else None

                description = item["event"]
                duration = (end - start).total_seconds() / 3600 if end else 0
                deductible = any(word in description.lower() for word in ['rain', 'waiting', 'no loading'])

                events.append({
                    'start': start,
                    'start_date': start.date(),
                    'start_time': start.time(),
                    'end': end,
                    'end_date': end.date() if end else None,
                    'end_time': end.time() if end else None,
                    'duration': duration,
                    'description': description,
                    'deductible': deductible,
                })
            except Exception as e:
                logger.error("Error parsing SOF event: %s | %s", item, e)

        # Extraction par mots-clés (inchangé)
        arrival = find_first_event(["arrived", "anchor at", "proceed to anchorage"])
        nor = find_first_event(["nor tendered", "nor accepted"])
        start_pump = find_first_event(["start pumping", "commenced loading"])
        end_pump = find_first_event(["end pumping", "completed loading"])
        hoses_connected = find_first_event(["hose connected"])
        hoses_disconnected = find_first_event(["hose off", "hose disconnected"])
        sailing_time = find_first_event(["sailing", "sb for sailing", "sea pilot on board"])

        return {
            'arrival_time': arrival,
            'notice_of_readiness_time': nor,
            'start_pumping': start_pump,
            'end_pumping': end_pump,
            'hoses_connected': hoses_connected,
            'hoses_disconnected': hoses_disconnected,
            'sailing_time': sailing_time,
            'events': events,
        }

    def transition_processing(self):
        pool = Pool()
        Statement = pool.get("sof.statement")
        SoFEvent = pool.get("sof.event")

        # Lecture des documents
        # text_sof = self.parse_pdf_text(self.sof.pdf)
        # logger.info("SOF TEXT:\n%s", text_sof)
        text_charter = self.parse_charter_text(self.sof.charter)

        # Extraction des champs
        # extracted_main = self.extract_fields_from_text(text_sof)
        # extracted_sof = self.extract_sof_data_and_events(text_sof)
        sof_json = [
            {
                "date": "2025-02-02",
                "time": "19:00",
                "event": "S/B FOR ARRIVAL - PROCEED TO CAK NO.2 ANCHORAGE",
                "end_time": "2025-02-03 21:30",
                "duration": {"iso_8601": "PT26H30M", "text": "26h 30m"},
            },
            {
                "date": "2025-02-02",
                "time": "21:30",
                "event": "DROP ANCHOR AT CAK NO.2 ANCHOR / ARR. CAN NOR TENDERED",
                "end_time": "2025-02-02 21:40",
                "duration": {"iso_8601": "-PT23H50M", "text": "10m"},
            },
            {
                "date": "2025-02-02",
                "time": "21:40",
                "event": "BROUGHT UP ANCHOR/F.W.E",
                "end_time": "2025-02-03 07:20",
                "duration": {"iso_8601": "PT9H40M", "text": "9h 40m"},
            },
            {
                "date": "2025-02-03",
                "time": "07:20",
                "event": "S.B.E. FOR SHIFTING",
                "end_time": "2025-02-03 07:45",
                "duration": {"iso_8601": "PT0H25M", "text": "25m"},
            },
            {
                "date": "2025-02-03",
                "time": "07:45",
                "event": "ANCHOR AWEIGHT",
                "end_time": "2025-02-03 10:45",
                "duration": {"iso_8601": "PT3H0M", "text": "3h"},
            },
            {
                "date": "2025-02-03",
                "time": "10:45",
                "event": "SEA PILOT ON BOARD",
                "end_time": "2025-02-03 14:00",
                "duration": {"iso_8601": "PT3H15M", "text": "3h 15m"},
            },
            {
                "date": "2025-02-03",
                "time": "14:00",
                "event": "EXCHANGED SEA PILOT TO RIVER PILOT",
                "end_time": "2025-02-03 17:45",
                "duration": {"iso_8601": "PT3H45M", "text": "3h 45m"},
            },
            {
                "date": "2025-02-03",
                "time": "17:45",
                "event": "DROP ANCHOR AT NANTONS DANGEROUS ANCHORAGE",
                "end_time": "2025-02-03 18:00",
                "duration": {"iso_8601": "PT0H15M", "text": "15m"},
            },
            {
                "date": "2025-02-03",
                "time": "18:00",
                "event": "BROUGHT UP ANCHOR/F.W.E/PILOT OFF",
                "end_time": "2025-02-04 06:30",
                "duration": {"iso_8601": "PT12H30M", "text": "12h 30m"},
            },
            {
                "date": "2025-02-04",
                "time": "06:30",
                "event": "S.B.E. FOR BERTHING",
                "end_time": "2025-02-04 07:10",
                "duration": {"iso_8601": "PT0H40M", "text": "40m"},
            },
            {
                "date": "2025-02-04",
                "time": "07:10",
                "event": "PILOT ON BOARD",
                "end_time": "2025-02-04 07:15",
                "duration": {"iso_8601": "PT0H5M", "text": "5m"},
            },
            {
                "date": "2025-02-04",
                "time": "07:15",
                "event": "ANCHOR AWEIGHT",
                "end_time": "2025-02-04 09:20",
                "duration": {"iso_8601": "PT2H5M", "text": "2h 5m"},
            },
            {
                "date": "2025-02-04",
                "time": "09:20",
                "event": "FWD TUG MADE FAST",
                "end_time": "2025-02-04 09:35",
                "duration": {"iso_8601": "PT0H15M", "text": "15m"},
            },
            {
                "date": "2025-02-04",
                "time": "09:35",
                "event": "FIRST LINE ASHORE",
                "end_time": "2025-02-04 08:50",
                "duration": {
                    "iso_8601": "-PT0H45M",
                    "text": "Incohérence temporelle",
                    "alert": "Vérifier l'heure de TUG OFF",
                },
            },
            {
                "date": "2025-02-04",
                "time": "08:50",
                "event": "TUG OFF",
                "end_time": "2025-02-04 09:55",
                "duration": {"iso_8601": "PT1H5M", "text": "1h 5m"},
            },
            {
                "date": "2025-02-04",
                "time": "09:55",
                "event": "ALL LINE MADE FAST/F.W.E / NOR ACCEPTED",
                "end_time": "2025-02-04 10:15",
                "duration": {"iso_8601": "PT0H20M", "text": "20m"},
            },
            {
                "date": "2025-02-04",
                "time": "10:15",
                "event": "GANGWAY DOWN/PILOT OFF",
                "end_time": "2025-02-04 10:20",
                "duration": {"iso_8601": "PT0H5M", "text": "5m"},
            },
            {
                "date": "2025-02-04",
                "time": "10:20",
                "event": "AGENT / BAMBRIATION ON BOARD",
                "end_time": "2025-02-04 11:10",
                "duration": {"iso_8601": "PT0H50M", "text": "50m"},
            },
            {
                "date": "2025-02-04",
                "time": "11:10",
                "event": "FREE-PRACTIQUE QUANTED",
                "end_time": "2025-02-04 11:40",
                "duration": {"iso_8601": "PT0H30M", "text": "30m"},
            },
            {
                "date": "2025-02-04",
                "time": "11:40",
                "event": "SURVEYOR & LOADING MASTER ON BOARD",
                "end_time": "2025-02-04 11:40",
                "duration": {
                    "iso_8601": "PT0H0M",
                    "text": "0m",
                    "note": "Début de SAFETY MEETING",
                },
            },
            {
                "date": "2025-02-04",
                "time": "11:40",
                "event": "SAFETY MEETING",
                "end_time": "2025-02-04 12:20",
                "duration": {"iso_8601": "PT0H40M", "text": "40m"},
            },
            {
                "date": "2025-02-04",
                "time": "11:45",
                "event": "TANK INSPECTION",
                "end_time": "2025-02-04 12:30",
                "duration": {"iso_8601": "PT0H45M", "text": "45m"},
            },
            {
                "date": "2025-02-04",
                "time": "13:10",
                "event": 'CARGO HOSE CONNECTED (3 x 8")',
                "end_time": "2025-02-04 13:25",
                "duration": {"iso_8601": "PT0H15M", "text": "15m"},
            },
            {
                "date": "2025-02-04",
                "time": "13:25",
                "event": "LEAKAGE TESTED",
                "end_time": "2025-02-04 13:45",
                "duration": {"iso_8601": "PT0H20M", "text": "20m"},
            },
            {
                "date": "2025-02-04",
                "time": "14:15",
                "event": "COMMENCED LOADING SULPHURIC ACID",
                "end_time": "2025-02-05 08:50",
                "duration": {"iso_8601": "PT18H35M", "text": "18h 35m"},
            },
            {
                "date": "2025-02-05",
                "time": "08:50",
                "event": "COMPLETED LOADING SULPHURIC ACID",
                "end_time": "2025-02-05 09:00",
                "duration": {"iso_8601": "PT0H10M", "text": "10m"},
            },
            {
                "date": "2025-02-05",
                "time": "08:50",
                "event": "AIR BLOWING",
                "end_time": "2025-02-05 09:00",
                "duration": {"iso_8601": "PT0H10M", "text": "10m"},
            },
            {
                "date": "2025-02-05",
                "time": "09:00",
                "event": "VILLAGING, SAMPLING AND CALCULATION",
                "end_time": "2025-02-05 11:00",
                "duration": {"iso_8601": "PT2H0M", "text": "2h"},
            },
            {
                "date": "2025-02-05",
                "time": "10:40",
                "event": 'HOSE OFF (308")',
                "end_time": "2025-02-05 11:40",
                "duration": {"iso_8601": "PT1H0M", "text": "1h"},
            },
            {
                "date": "2025-02-05",
                "time": "11:40",
                "event": "CARGO DOCUMENT COMPLETED",
                "end_time": "2025-02-05 11:40",
                "duration": {"iso_8601": "PT0H0M", "text": "Événement final"},
            },
        ]
        extracted_sof = self.extract_sof_data_and_events_from_json(sof_json)

        # Création du Statement principal
        statement = Statement.create(
            [
                {
                    "shipment": self.records[0].id,
                    "document": self.sof.pdf,
                    "document_name": self.sof.filename,
                    "charter_text": text_charter,
                    # **extracted_main,
                    **{k: v for k, v in extracted_sof.items() if k != "events"},
                }
            ]
        )[0]

        # Création des événements liés
        for e in extracted_sof["events"]:
            SoFEvent.create(
                [
                    {
                        "statement": statement.id,
                        "start": e["start"],
                        "start_date": e["start_date"],
                        "start_time": e["start_time"],
                        "end": e["end"],
                        "end_date": e["end_date"],
                        "end_time": e["end_time"],
                        "duration": e["duration"],
                        "description": e["description"],
                        "deductible": e["deductible"],
                    }
                ]
            )

        return "end"

class SofUpdate(Wizard):
    "Update shipment"
    __name__ = "sof.update"

    start = StateTransition()
    
    def transition_start(self):
        Shipment = Pool().get('stock.shipment.in')
        Sof = Pool().get('sof.statement')
        for r in self.records:
            sh = Shipment(r.id)
            sof = Sof()
            sof.chart = 6
            sof.sof = 5
            sh.sof = [sof]
            Shipment.save([sh])

        return 'end'

class AccountMoveLine(metaclass=PoolMeta):
    "Account move line"
    __name__ = 'account.move.line'
    revaluate = fields.Integer("Linked line")
    lot = fields.Many2One('lot.lot',"Lot")
    fee = fields.Many2One('fee.fee',"Fee")

class AccountMove(metaclass=PoolMeta):
    "Account move"
    __name__ = 'account.move'

    def IsBankMove(self):
        PM = Pool().get('account.invoice.payment.method')
        if self.lines:
            for m in self.lines:
                pm = PM.search(['debit_account','=',m.account])
                if pm:
                    return True
                else:
                    pm = PM.search(['credit_account','=',m.account])
                    if pm:
                        return True
        return False

class Account(metaclass=PoolMeta):
    "Account"
    __name__ = 'account.account'

    def get_foreign_currencies(self):
        Currency = Pool().get('currency.currency')
        cursor = Transaction().connection.cursor()
        cursor.execute("""
            SELECT DISTINCT second_currency
            FROM account_move_line
            WHERE account = %s
            AND second_currency IS NOT NULL
        """, [self.id])

        company_currency = self.company.currency
        currencies = []
        for (currency_id,) in cursor.fetchall():
            cur = Currency(currency_id)
            if cur != company_currency:
                currencies.append(cur)

        return currencies
    
    def get_lines_curr(self,curr):
        MoveLine = Pool().get('account.move.line')
        lines = MoveLine.search([('account','=',self.id),('second_currency','=',curr.id),('revaluate','=',None),('reconciliation','=',None)])
        return lines
    
    def get_all_lines_curr(self,curr):
        MoveLine = Pool().get('account.move.line')
        lines = MoveLine.search([('account','=',self.id)])#,('second_currency','=',curr.id)])
        return lines

    def get_lines_curr_reval(self,id):
        MoveLine = Pool().get('account.move.line')
        lines = MoveLine.search([('account','=',self.id),('revaluate','=',id)])
        return lines
    
    def revaluate_fx(self,reval_date,after):
        Currency = Pool().get('currency.currency')
        MoveLine = Pool().get('account.move.line')
        Configuration = Pool().get('account.configuration')
        configuration = Configuration(1)
        Period = Pool().get('account.period')
        Move = Pool().get('account.move')
        currencies = self.get_foreign_currencies()
        logger.info("REVALUATE_ACCOUNT:%s",self.id)
        if currencies:
            for curr in currencies:
                logger.info("REVALUATE_CURR:%s",curr.id)
                if self.fx_eval_detail:
                    lines = self.get_lines_curr(curr)
                    logger.info("REVALUATE_DETAIL:%s",lines)
                    if lines:
                        for l in lines:
                            reval_lines = self.get_lines_curr_reval(l.id)
                            logger.info("REVALUATE_REVAL_LINES:%s",l)
                            with Transaction().set_context(date=reval_date):
                                amount_converted = Currency.compute(curr,l.amount_second_currency, self.company.currency)
                                amount_reval = sum([(e.debit-e.credit) for e in reval_lines])
                                to_add = amount_converted - ((l.debit-l.credit) + amount_reval)
                                if to_add != 0:
                                    second_amount_to_add = Currency.compute(self.company.currency,to_add,curr)
                                    line = MoveLine()
                                    line.account = self.id
                                    line.revaluate = l.id
                                    line.credit = -to_add if to_add < 0 else 0
                                    line.debit = to_add if to_add > 0 else 0
                                    line.lot = l.lot
                                    line.party = l.party
                                    line.maturity_date = reval_date
                                    logger.info("REVALUATE_ACC:%s",line)
                                    line_ = MoveLine()
                                    if to_add < 0:
                                        line_.account = configuration.get_multivalue('currency_exchange_credit_account', company=self.company.id)
                                    else:
                                        line_.account = configuration.get_multivalue('currency_exchange_debit_account', company=self.company.id)
                                    line_.credit = to_add if to_add > 0 else 0
                                    line_.debit = -to_add if to_add < 0 else 0
                                    line_.lot = l.lot
                                    line_.maturity_date = reval_date
                                    logger.info("REVALUATE_EX:%s",line_)
                                    move = Move()
                                    move.journal = configuration.get_multivalue('currency_exchange_journal', company=self.company.id)
                                    period = Period.find(self.company, date=reval_date)
                                    move.date = reval_date
                                    move.period = period
                                    #move.origin = forex
                                    move.company = self.company
                                    move.lines = [line,line_]
                                    Move.save([move])
                    all_lines = self.get_all_lines_curr(curr)
                    sum_second_amount = sum([e.amount_second_currency for e in all_lines if e.amount_second_currency])
                    logger.info("REVALUATE_SUM_SECOND:%s",sum_second_amount)
                    if sum_second_amount == Decimal(0):
                        base_curr_sum = sum([(e.debit-e.credit) for e in all_lines])
                        if abs(base_curr_sum) > 0:
                            line = MoveLine()
                            line.account = self.id
                            line.party = 1904 #TBN
                            line.credit = base_curr_sum if base_curr_sum > 0 else 0
                            line.debit = -base_curr_sum if base_curr_sum < 0 else 0
                            line.maturity_date = reval_date
                            logger.info("REVALUATE_ACC_:%s",line)
                            line_ = MoveLine()
                            if base_curr_sum < 0:
                                line_.account = configuration.get_multivalue('currency_exchange_credit_account', company=self.company.id)
                            else:
                                line_.account = configuration.get_multivalue('currency_exchange_debit_account', company=self.company.id)
                            line_.credit = -base_curr_sum if base_curr_sum < 0 else 0
                            line_.debit = base_curr_sum if base_curr_sum > 0 else 0
                            line_.maturity_date = reval_date
                            logger.info("REVALUATE_EX:%s",line_)
                            move = Move()
                            move.journal = configuration.get_multivalue('currency_exchange_journal', company=self.company.id)
                            period = Period.find(self.company, date=reval_date)
                            move.date = reval_date
                            move.period = period
                            #move.origin = forex
                            move.company = self.company
                            move.lines = [line,line_]
                            Move.save([move])

                else:
                    lines = self.get_all_lines_curr(curr)
                    with Transaction().set_context(date=reval_date):
                        sum_second_amount = sum([e.amount_second_currency for e in lines if e.amount_second_currency])
                        logger.info("REVALUATE_SECOND:%s",sum_second_amount)
                        amount_converted = Currency.compute(curr,sum_second_amount, self.company.currency)
                        logger.info("REVALUATE_SECOND2:%s",amount_converted)
                        total_amount = sum([(e.debit-e.credit) for e in lines])
                        logger.info("REVALUATE_SECOND3:%s",total_amount)
                        to_add = amount_converted - total_amount
                        if to_add != 0:
                            second_amount_to_add = Currency.compute(self.company.currency,to_add,curr)
                            line = MoveLine()
                            line.account = self.id
                            line.credit = -to_add if to_add < 0 else 0
                            line.debit = to_add if to_add > 0 else 0
                            line.party = self.company.party.id
                            line.maturity_date = reval_date
                            logger.info("REVALUATE_ACC:%s",line)
                            line_ = MoveLine()
                            if to_add < 0:
                                line_.account = configuration.get_multivalue('currency_exchange_credit_account', company=self.company.id)
                            else:
                                line_.account = configuration.get_multivalue('currency_exchange_debit_account', company=self.company.id)
                            line_.credit = to_add if to_add > 0 else 0
                            line_.debit = -to_add if to_add < 0 else 0
                            line_.maturity_date = reval_date
                            logger.info("REVALUATE_EX:%s",line_)
                            move = Move()
                            move.journal = configuration.get_multivalue('currency_exchange_journal', company=self.company.id)
                            period = Period.find(self.company, date=reval_date)
                            move.date = reval_date
                            move.period = period
                            #move.origin = forex
                            move.company = self.company
                            move.lines = [line,line_]
                            Move.save([move])

class Revaluate(Wizard):
    'Revaluate'
    __name__ = 'account.revaluate'
    start = StateView(
        'account.revaluate.start',
        'purchase_trade.revaluate_start_view_form', [
            Button("Cancel", 'end', 'tryton-cancel'),
            Button("Revaluate", 'revaluate', 'tryton-ok', default=True),
            ])
    revaluate = StateTransition()

    def transition_revaluate(self):
        Account = Pool().get('account.account')
        accounts = Account.search([('fx_eval','=',True)])
        if accounts:
            for acc in accounts:
                acc.revaluate_fx(self.start.revaluation_date,self.start.delete_after)

        return 'end'

class RevaluateStart(ModelView):
    "Revaluate"
    __name__ = 'account.revaluate.start'
    revaluation_date = fields.Date(
        "Revaluation date")
    delete_after = fields.Boolean(
        "Delete lines after revaluation date")

    @classmethod
    def default_revaluation_date(cls):
        Date = Pool().get('ir.date')
        return Date.today()

    @classmethod
    def default_delete_after(cls):
        return False


class ShipmentTemplateReportMixin:

    @classmethod
    def _get_purchase_trade_configuration(cls):
        Configuration = Pool().get('purchase_trade.configuration')
        configurations = Configuration.search([], limit=1)
        return configurations[0] if configurations else None

    @classmethod
    def _get_action_report_path(cls, action):
        if isinstance(action, dict):
            return action.get('report') or ''
        return getattr(action, 'report', '') or ''

    @classmethod
    def _resolve_template_path(cls, field_name, default_prefix):
        config = cls._get_purchase_trade_configuration()
        template = getattr(config, field_name, '') if config else ''
        template = (template or '').strip()
        if not template:
            raise UserError('No template found')
        if '/' not in template:
            return f'{default_prefix}/{template}'
        return template

    @classmethod
    def _get_resolved_action(cls, action):
        report_path = cls._resolve_configured_report_path(action)
        if isinstance(action, dict):
            resolved = dict(action)
            resolved['report'] = report_path
            return resolved
        setattr(action, 'report', report_path)
        return action

    @classmethod
    def _execute(cls, records, header, data, action):
        resolved_action = cls._get_resolved_action(action)
        return super()._execute(records, header, data, resolved_action)


class ShipmentShippingReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
    __name__ = 'stock.shipment.in.shipping'

    @classmethod
    def _resolve_configured_report_path(cls, action):
        return cls._resolve_template_path(
            'shipment_shipping_report_template', 'stock')


class ShipmentInsuranceReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
    __name__ = 'stock.shipment.in.insurance'

    @classmethod
    def _resolve_configured_report_path(cls, action):
        return cls._resolve_template_path(
            'shipment_insurance_report_template', 'stock')


class ShipmentPackingListReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
    __name__ = 'stock.shipment.in.packing_list'

    @classmethod
    def _resolve_configured_report_path(cls, action):
        return cls._resolve_template_path(
            'shipment_packing_list_report_template', 'stock')


class ShipmentLinkageReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
    __name__ = 'stock.shipment.in.linkage'

    @classmethod
    def _resolve_configured_report_path(cls, action):
        return cls._resolve_template_path(
            'shipment_linkage_report_template', 'stock')


class LinkageTemplateReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
    @classmethod
    def _resolve_configured_report_path(cls, action):
        return cls._resolve_template_path(
            'shipment_linkage_report_template', 'stock')


class LotReportLinkageRecord:

    def __init__(self, lot, lot_report=None, linkage_status='PROVISIONAL'):
        self.lot = lot
        self.lot_report = lot_report
        self.id = getattr(lot_report, 'id', None) or getattr(lot, 'id', None)
        self.report_linkage_status = linkage_status

    @property
    def rec_name(self):
        return (
            getattr(self.lot_report, 'rec_name', None)
            or getattr(self.lot, 'rec_name', None)
            or getattr(self.lot, 'lot_name', None)
            or str(self.id))

    def set_lang(self, language=None):
        set_lang = getattr(self.lot, 'set_lang', None)
        if callable(set_lang):
            set_lang(language)

    def get_common_context(self, commoncontext):
        shipment = getattr(self.lot, 'lot_shipment_in', None)
        get_common_context = getattr(shipment, 'get_common_context', None)
        if callable(get_common_context):
            return get_common_context(commoncontext)
        return None

    @staticmethod
    def _report_record_key(record):
        return ShipmentIn._report_record_key(record)

    @classmethod
    def _report_unique_records(cls, records):
        return ShipmentIn._report_unique_records(records)

    @staticmethod
    def _report_linkage_lot_purchase_line(lot):
        return ShipmentIn._report_linkage_lot_purchase_line(lot)

    @staticmethod
    def _report_linkage_lot_sale_line(lot):
        return ShipmentIn._report_linkage_lot_sale_line(lot)

    @staticmethod
    def _report_rec_name(record):
        return ShipmentIn._report_rec_name(record)

    @classmethod
    def _report_number(cls, record):
        return ShipmentIn._report_number(record)

    @staticmethod
    def _report_date(value):
        return ShipmentIn._report_date(value)

    @staticmethod
    def _report_amount(value):
        return ShipmentIn._report_amount(value)

    @staticmethod
    def _report_price(value):
        return ShipmentIn._report_price(value)

    @classmethod
    def _report_currency_code(cls, record):
        return ShipmentIn._report_currency_code(record)

    @classmethod
    def _report_unit_symbol(cls, line):
        return ShipmentIn._report_unit_symbol(line)

    @classmethod
    def _report_line_quantity(cls, line):
        return ShipmentIn._report_line_quantity(line)

    @classmethod
    def _report_line_amount(cls, line):
        return ShipmentIn._report_line_amount(line)

    @classmethod
    def _report_fee_amount(cls, fee):
        return ShipmentIn._report_fee_amount(fee)

    @classmethod
    def _report_linkage_fee_label(cls, fee):
        return ShipmentIn._report_linkage_fee_label(fee)

    @classmethod
    def _report_column(cls, rows, index, amount=False):
        return ShipmentIn._report_column(rows, index, amount=amount)

    @classmethod
    def _report_table_rows(cls, rows, names, amount_names=()):
        return ShipmentIn._report_table_rows(rows, names, amount_names)

    @staticmethod
    def _format_report_quantity(value, digits='0.001'):
        return ShipmentIn._format_report_quantity(value, digits=digits)

    @staticmethod
    def _get_report_unit_text(unit):
        return ShipmentIn._get_report_unit_text(unit)

    def _get_report_primary_move(self):
        return getattr(self.lot, 'move', None)

    def _get_report_linkage_lots(self):
        return [self.lot]

    @property
    def vessel(self):
        shipment = getattr(self.lot, 'lot_shipment_in', None)
        return getattr(shipment, 'vessel', None)

    _get_report_linkage_purchase_lines = (
        ShipmentIn._get_report_linkage_purchase_lines)
    _get_report_linkage_sale_lines = ShipmentIn._get_report_linkage_sale_lines
    _get_report_linkage_purchase = ShipmentIn._get_report_linkage_purchase
    _get_report_linkage_sale = ShipmentIn._get_report_linkage_sale
    _get_report_linkage_product = ShipmentIn._get_report_linkage_product

    def _get_report_linkage_fees(self):
        shipment = getattr(self.lot, 'lot_shipment_in', None)
        fees = []
        seen = set()

        def add_fee(fee):
            if not fee:
                return
            key = self._report_record_key(fee)
            if key in seen:
                return
            seen.add(key)
            fees.append(fee)

        for fee in getattr(shipment, 'fees', []) or []:
            add_fee(fee)
        for line in (
                self._get_report_linkage_purchase_lines()
                + self._get_report_linkage_sale_lines()):
            for fee in getattr(line, 'fees', []) or []:
                add_fee(fee)
        return [
            fee for fee in fees
            if self._report_linkage_fee_applies_to_report_lots(fee)]

    _get_report_linkage_fee_lots = ShipmentIn._get_report_linkage_fee_lots
    _report_linkage_fee_applies_to_report_lots = (
        ShipmentIn._report_linkage_fee_applies_to_report_lots)
    _get_report_linkage_fee_trade_line = (
        ShipmentIn._get_report_linkage_fee_trade_line)
    _get_report_linkage_fee_trade = ShipmentIn._get_report_linkage_fee_trade
    _get_report_linkage_fee_pairs = ShipmentIn._get_report_linkage_fee_pairs
    _report_linkage_fee_type = ShipmentIn._report_linkage_fee_type

    _get_report_linkage_summary_rows = (
        ShipmentIn._get_report_linkage_summary_rows)
    _get_report_linkage_movement_rows = (
        ShipmentIn._get_report_linkage_movement_rows)
    _get_report_linkage_movement_row = (
        ShipmentIn._get_report_linkage_movement_row)
    _get_report_linkage_pricing_rows = (
        ShipmentIn._get_report_linkage_pricing_rows)
    _get_report_linkage_detail_rows = (
        ShipmentIn._get_report_linkage_detail_rows)
    _get_report_linkage_detail_row = (
        ShipmentIn._get_report_linkage_detail_row)
    _get_report_linkage_fee_detail_row = (
        ShipmentIn._get_report_linkage_fee_detail_row)

    @property
    def report_linkage_title(self):
        title = ShipmentIn.report_linkage_title.fget(self)
        status = getattr(self, 'report_linkage_status', None)
        if status:
            title = ' - '.join(part for part in [title, status] if part)
        return title

    report_linkage_desk = ShipmentIn.report_linkage_desk
    report_linkage_book = ShipmentIn.report_linkage_book
    report_linkage_strategy = ShipmentIn.report_linkage_strategy

    @property
    def report_linkage_bl_date(self):
        shipment = getattr(self.lot, 'lot_shipment_in', None)
        bl_date = getattr(shipment, 'bl_date', None)
        if not bl_date:
            move = self._get_report_primary_move()
            bl_date = getattr(move, 'bldate', None)
        return bl_date and bl_date.strftime('%A, %B %d, %Y') or ''

    report_linkage_trading_unit = ShipmentIn.report_linkage_trading_unit
    report_linkage_finance_user = ShipmentIn.report_linkage_finance_user
    report_linkage_summary_groups = ShipmentIn.report_linkage_summary_groups
    report_linkage_summary_cost_types = (
        ShipmentIn.report_linkage_summary_cost_types)
    report_linkage_summary_estimated = (
        ShipmentIn.report_linkage_summary_estimated)
    report_linkage_summary_validated = (
        ShipmentIn.report_linkage_summary_validated)
    report_linkage_summary_rows = ShipmentIn.report_linkage_summary_rows
    report_linkage_movement_types = ShipmentIn.report_linkage_movement_types
    report_linkage_movement_references = (
        ShipmentIn.report_linkage_movement_references)
    report_linkage_movement_counterparts = (
        ShipmentIn.report_linkage_movement_counterparts)
    report_linkage_movement_commodities = (
        ShipmentIn.report_linkage_movement_commodities)
    report_linkage_movement_quantities = (
        ShipmentIn.report_linkage_movement_quantities)
    report_linkage_movement_deliveries = (
        ShipmentIn.report_linkage_movement_deliveries)
    report_linkage_movement_basis = ShipmentIn.report_linkage_movement_basis
    report_linkage_movement_periods = (
        ShipmentIn.report_linkage_movement_periods)
    report_linkage_movement_from_dates = (
        ShipmentIn.report_linkage_movement_from_dates)
    report_linkage_movement_to_dates = (
        ShipmentIn.report_linkage_movement_to_dates)
    report_linkage_movement_rows = ShipmentIn.report_linkage_movement_rows
    report_linkage_bank_deal = ShipmentIn.report_linkage_bank_deal
    report_linkage_bank_type = ShipmentIn.report_linkage_bank_type
    report_linkage_bank_name = ShipmentIn.report_linkage_bank_name
    report_linkage_lc_type = ShipmentIn.report_linkage_lc_type
    report_linkage_lc_number = ShipmentIn.report_linkage_lc_number
    report_linkage_lc_amount = ShipmentIn.report_linkage_lc_amount
    report_linkage_lc_expiry_date = ShipmentIn.report_linkage_lc_expiry_date
    report_linkage_pricing_deals = ShipmentIn.report_linkage_pricing_deals
    report_linkage_pricing_sides = ShipmentIn.report_linkage_pricing_sides
    report_linkage_pricing_types = ShipmentIn.report_linkage_pricing_types
    report_linkage_pricing_symbols = ShipmentIn.report_linkage_pricing_symbols
    report_linkage_pricing_periods = ShipmentIn.report_linkage_pricing_periods
    report_linkage_pricing_input_prices = (
        ShipmentIn.report_linkage_pricing_input_prices)
    report_linkage_pricing_rows = ShipmentIn.report_linkage_pricing_rows
    report_linkage_detail_groups = ShipmentIn.report_linkage_detail_groups
    report_linkage_detail_cost_types = (
        ShipmentIn.report_linkage_detail_cost_types)
    report_linkage_detail_counterparts = (
        ShipmentIn.report_linkage_detail_counterparts)
    report_linkage_detail_sources = ShipmentIn.report_linkage_detail_sources
    report_linkage_detail_unit_prices = (
        ShipmentIn.report_linkage_detail_unit_prices)
    report_linkage_detail_quantities = (
        ShipmentIn.report_linkage_detail_quantities)
    report_linkage_detail_estimated = (
        ShipmentIn.report_linkage_detail_estimated)
    report_linkage_detail_validated = (
        ShipmentIn.report_linkage_detail_validated)
    report_linkage_detail_rows = ShipmentIn.report_linkage_detail_rows


class LotReportLinkageReport(LinkageTemplateReport):
    __name__ = 'lot.report.linkage'
    linkage_status = 'PROVISIONAL'

    @classmethod
    def _get_records(cls, ids, model, data):
        LotReport = Pool().get('lot.report')
        records = LotReport.browse(ids)
        linkage_records = []
        for record in records:
            if getattr(record, 'r_lot_type', None) != 'physic':
                raise UserError(
                    'Linkage report is only available on physical lot lines.')
            lot = getattr(record, 'r_lot_p', None) or getattr(
                record, 'r_lot_s', None)
            if not lot:
                raise UserError(
                    'Linkage report needs a physical lot on the selected line.')
            linkage_records.append(LotReportLinkageRecord(
                    lot, record, linkage_status=cls.linkage_status))
        return linkage_records


class LotReportLinkageFinalReport(LotReportLinkageReport):
    __name__ = 'lot.report.linkage.final'
    linkage_status = 'FINAL'


class ShipmentCOOReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
    __name__ = 'stock.shipment.in.coo'

    @classmethod
    def _resolve_configured_report_path(cls, action):
        return cls._resolve_template_path(
            'shipment_coo_report_template', 'stock')
