# 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 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__) 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") lotqt = fields.One2Many('lot.qt','lot_move',"Lots") lot = fields.Many2One('lot.lot',"Lot") 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") @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") class ShipmentIn(metaclass=PoolMeta): __name__ = 'stock.shipment.in' 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",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") 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') 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') sof = fields.One2Many('sof.statement', 'shipment',"Demurrage calculations") del_from = fields.Date("Delivery period from") del_to = fields.Date("to") estimated_date = fields.One2Many('pricing.estimated','shipment_in',"Estimated date") carrier_ = fields.Many2One('party.party',"Carrier") 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([ ('bulk', 'Bulk'), ('container', 'Container'), ], 'Cargo Mode', required=True) vessel_type = fields.Function(fields.Many2One('stock.vessel.type',"Vessel type"),'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._buttons.update({ 'compute': {}, 'send': {}, }) def get_vessel_type(self,name=None): if self.vessel: return self.vessel.vessel_type 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) 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_account_of(self): line = self._get_report_trade_line() trade = getattr(line, 'sale', None) or getattr(line, 'purchase', None) 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_insurance_goods_description(self): name = self.report_product_name description = self.report_product_description if description and description != name: return ' - '.join(part for part in [name, description] if part) return name or description @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 '' @property def report_insurance_contact_surveyor(self): return self.report_insurance_surveyor @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_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) 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') 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 price and mode and curr and unit: 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 = [ "

Hi,

", "

Please find details below for the requested control

", ] lines.append( "

" f"BL number: {self.bl_number} | " f"Vessel: {vessel} | " f"ETA: {self.etad}" "

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

" f"Customer: {customer} | " f"Invoice Nb: {inv_nb} | " f"Invoice Date: {inv_date}" "

" ) lines.append( "

" f"Nb Bales: {tot_bale} | " f"Net Qt: {tot_net} {unit} | " f"Gross Qt: {tot_gross} {unit}" "

" ) 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("ROWS:%s",rows) inv_date = None inv_nb = None if rows: sale_line = None for row in rows: logger.info("ROW:%s",row) #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() lot_unit = str(row[5]).strip().lower() product = str(row[6]).strip().upper() lot_net_weight = Decimal(row[4]) logger.info("LOT_NET_WEIGHT:%s",lot_net_weight) 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("DECLARATION_KEY:%s",dec_key) declaration = SaleLine.search(['note','=',dec_key]) if declaration: sale_line = declaration[0] logger.info("WITH_DEC:%s",sale_line) vlot = sale_line.lots[0] lqt = LotQt.search([('lot_s','=',vlot.id)]) if lqt: for lq in lqt: if lq.lot_p: logger.info("VLOT_P:%s",lq.lot_p) sale_line.quantity_theorical += round(lot_net_weight,2) SaleLine.save([sale_line]) lq.lot_p.updateVirtualPart(round(lot_net_weight,2),self,lq.lot_s) vlot.set_current_quantity(round(lot_net_weight,2),round(lot_gross_weight,2),1) Lot.save([vlot]) 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) sale_line.unit_price = sale_line.get_price_linked_currency() 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) 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])]) 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.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] ContractFactory.create_contracts( ct.contracts, type_=ct.type, ct=ct, ) #Lots creation vlot = sale_line.lots[0] lqt = LotQt.search([('lot_s','=',vlot.id),('lot_p','>',0)]) if lqt and vlot.lot_quantity > 0: lqt = lqt[0] 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("ADD_LOT:%s",int(chunk_key)) LotQt.add_physical_lots(lqt,[l]) return inv_date,inv_nb def html_to_text(self,html_content): text = re.sub(r"", "\n", html_content, flags=re.IGNORECASE) text = re.sub(r"", "\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 @ModelView.button def send(cls, shipments): Date = Pool().get('ir.date') 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": sh.html_to_text(sh.instructions), "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 def compute(cls, shipments): Sof = Pool().get('sof.statement') for sh in shipments: if sh.sof: for s in sh.sof: if s.laytime_clause_hour: t = s.laytime_clause_hour # instance de datetime.time delta = datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second) s.laytime_commenced = s.notice_of_readiness_time + delta s.laytime_allowed = round(s.quantity / s.pumping_rate_o,2) s.laytime_completed = s.hoses_disconnected #s.laytime_completed = s.laytime_commenced + datetime.timedelta(hours=s.laytime_allowed) total_time = (s.laytime_completed - s.laytime_commenced).total_seconds() / 3600.0 logger.info("COMPUTE_DEDUCTION3:%s",total_time) s.actual_time_used = round(total_time,2) s.deductions = round(sum([e.duration for e in s.sof_events if e.deductible == True]),2) effective_time = round(total_time - s.deductions,2) s.laytime_used = effective_time s.laytime_balance = round(s.laytime_allowed - s.laytime_used,2) penalty = Decimal(s.laytime_balance) * (s.demurrage_rate_o / 24) s.compensation_amount = round(penalty, 2) Sof.save([s]) 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_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]) 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'
loading...' 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 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 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' # === 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)') 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") 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') hoses_connected = fields.DateTime('Hoses Connected') start_pumping = fields.DateTime('Start Pumping') end_pumping = fields.DateTime('End Pumping') hoses_disconnected = fields.DateTime('Hoses Disconnected') sailing_time = fields.DateTime('Sailing Time') # === C. Résultats du calcul === laytime_commenced = fields.DateTime('Laytime Commenced') laytime_completed = fields.DateTime('Laytime Completed') actual_time_used = fields.Float('Actual Time Used (hours)', readonly=True) 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') notes = fields.Text('Remarks / Notes') def get_qt(self,name): if self.shipment.incoming_moves: return sum([e.quantity for e in self.shipment.incoming_moves]) 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") 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')