Files
tradon/modules/purchase_trade/stock.py
2026-06-04 19:27:50 +02:00

3433 lines
136 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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__)
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")
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 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_event = fields.Selection([
(None, ''),
('notice_of_readiness', 'Notice of Readiness'),
('arrival', 'Arrival'),
('hoses_connected', 'Hoses Connected'),
('start_pumping', 'Start Pumping'),
('end_pumping', 'End Pumping'),
('hoses_disconnected', 'Hoses Disconnected'),
('sailing', 'Sailing'),
], "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_end_event = fields.Selection([
(None, ''),
('notice_of_readiness', 'Notice of Readiness'),
('arrival', 'Arrival'),
('hoses_connected', 'Hoses Connected'),
('start_pumping', 'Start Pumping'),
('end_pumping', 'End Pumping'),
('hoses_disconnected', 'Hoses Disconnected'),
('sailing', 'Sailing'),
], "Laytime End Event")
turn_time = fields.Numeric("Turn Time", digits=(16, 4))
turn_time_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_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')
to_location = fields.Many2One('stock.location', 'To location')
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')
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("Delivery 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'),
], '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 = []
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)])
super().write(*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_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):
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_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')
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("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)
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])])
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"<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
@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:
laytime_start = s._applied_laytime_start()
if laytime_start:
s.laytime_commenced = laytime_start
quantity = float(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'
@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):
lot_ids = self._pnl_lot_ids()
if not lot_ids:
return []
ValuationLine = Pool().get('valuation.valuation.line')
lines = ValuationLine.search(
[('lot', 'in', lot_ids)],
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
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
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'
_event_labels = {
'notice_of_readiness': 'Notice of Readiness',
'arrival': 'Arrival',
'hoses_connected': 'Hoses Connected',
'start_pumping': 'Start Pumping',
'end_pumping': 'End Pumping',
'hoses_disconnected': 'Hoses Disconnected',
'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'),
'hoses_connected': ('hoses_connected_date', 'hoses_connected_hour'),
'start_pumping': ('start_pumping_date', 'start_pumping_hour'),
'end_pumping': ('end_pumping_date', 'end_pumping_hour'),
'hoses_disconnected': (
'hoses_disconnected_date', 'hoses_disconnected_hour'),
'sailing_time': ('sailing_date', 'sailing_hour'),
'laytime_commenced': (
'laytime_commenced_date', 'laytime_commenced_hour'),
'laytime_completed': (
'laytime_completed_date', 'laytime_completed_hour'),
}
# === A. Informations générales ===
shipment = fields.Many2One('stock.shipment.in', 'Shipment', required=True, ondelete='CASCADE')
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")
charter_party = fields.Many2One('stock.charter.party', "Charter Party")
applied_conditions = fields.Function(
fields.One2Many('charter.condition', '', "Available Conditions"),
'get_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')
hoses_connected = fields.DateTime('Hoses Connected')
hoses_connected_date = fields.Date('Hoses Connected Date')
hoses_connected_hour = fields.Time('Hoses Connected Time')
start_pumping = fields.DateTime('Start Pumping')
start_pumping_date = fields.Date('Start Pumping Date')
start_pumping_hour = fields.Time('Start Pumping Time')
end_pumping = fields.DateTime('End Pumping')
end_pumping_date = fields.Date('End Pumping Date')
end_pumping_hour = fields.Time('End Pumping Time')
hoses_disconnected = fields.DateTime('Hoses Disconnected')
hoses_disconnected_date = fields.Date('Hoses Disconnected Date')
hoses_disconnected_hour = fields.Time('Hoses Disconnected Time')
sailing_time = fields.DateTime('Sailing Time')
sailing_date = fields.Date('Sailing Date')
sailing_hour = fields.Time('Sailing Time')
notice_of_readiness_date = fields.Date('Notice of Readiness Date')
notice_of_readiness_hour = fields.Time('Notice of Readiness Time')
# === C. Résultats du calcul ===
laytime_commenced = fields.DateTime('Laytime Commenced')
laytime_commenced_date = fields.Date(
'Laytime Commenced Date', readonly=True)
laytime_commenced_hour = fields.Time(
'Laytime Commenced Time', readonly=True)
laytime_completed = fields.DateTime('Laytime Completed')
laytime_completed_date = fields.Date(
'Laytime Completed Date', readonly=True)
laytime_completed_hour = fields.Time(
'Laytime Completed Time', readonly=True)
actual_time_used = fields.Float('Actual Time Used (hours)', readonly=True)
deductions = fields.Float('Deductions (hours)', readonly=True)
laytime_used = fields.Float('Laytime Used (hours)', readonly=True)
laytime_balance = fields.Float('Laytime Balance (hours)', readonly=True)
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):
if self.shipment and self.shipment.charter_party and not self.charter_party:
self.charter_party = self.shipment.charter_party
self._set_default_applied_condition()
@fields.depends('charter_party', 'applied_condition')
def on_change_charter_party(self):
self._set_default_applied_condition()
def _set_default_applied_condition(self):
if self.applied_condition:
return
conditions = getattr(self.charter_party, 'conditions', None) or []
if len(conditions) == 1:
self.applied_condition = conditions[0]
def get_owner_charter_conditions(self, name=None):
if self.charter_party:
return ShipmentIn._unique_condition_ids(self.charter_party.conditions)
if self.shipment:
return self.shipment.get_owner_charter_conditions(name)
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 []
def get_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))
@staticmethod
def _rate_category(rate):
rate_type = getattr(rate, 'rate_type', None)
category = getattr(rate_type, 'category', None)
if category:
return category
label = ' '.join(filter(None, [
getattr(rate_type, 'name', None),
getattr(rate, 'description', None),
])).lower()
for category in ('demurrage', 'despatch', 'pumping'):
if category in label:
return category
def _condition_rate(self, category):
condition = getattr(self, 'applied_condition', None)
for rate in getattr(condition, 'rates', None) or []:
if self._rate_category(rate) == category and rate.rate is not None:
return Decimal(str(rate.rate))
def _applied_laytime_allowed(self, quantity):
condition = getattr(self, 'applied_condition', None)
if condition and condition.laytime_allowed is not None:
allowed = Decimal(str(condition.laytime_allowed))
if condition.laytime_unit in {
'days', 'wwd', 'wwd_shex', 'wwd_shinc'}:
allowed *= Decimal(24)
return float(allowed)
pumping_rate = self._condition_rate('pumping')
if pumping_rate:
return round(float(Decimal(str(quantity or 0)) / pumping_rate), 2)
legacy_pumping = self.pumping_rate_o or self.pumping_rate
if legacy_pumping:
return round(float(quantity or 0) / float(legacy_pumping), 2)
def _applied_turn_delta(self):
condition = getattr(self, 'applied_condition', None)
if condition:
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:
hours = Decimal(str(value))
if unit == 'days':
hours *= Decimal(24)
return datetime.timedelta(hours=float(hours))
if self.laytime_clause_hour:
t = self.laytime_clause_hour
elif self.laytime_clause_hour_o:
t = self.laytime_clause_hour_o
else:
return datetime.timedelta(0)
return datetime.timedelta(
hours=t.hour, minutes=t.minute, seconds=t.second)
def _condition_event_datetime(self, event):
return {
'notice_of_readiness': self.notice_of_readiness_time,
'arrival': self.arrival_time,
'hoses_connected': self.hoses_connected,
'start_pumping': self.start_pumping,
'end_pumping': self.end_pumping,
'hoses_disconnected': self.hoses_disconnected,
'sailing': self.sailing_time,
}.get(event)
def _laytime_start_base(self):
condition = getattr(self, 'applied_condition', None)
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
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._condition_rate('demurrage')
if rate is None:
rate = Decimal(str(self.demurrage_rate_o or self.demurrage_rate or 0))
return round(balance * rate / Decimal(24), 2)
if balance > 0:
rate = self._condition_rate('despatch')
if rate is not None:
self.compensation_type = 'despatch'
return round(balance * rate / Decimal(24), 2)
self.compensation_type = None
return Decimal(0)
@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)
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_rate_text(self, category):
rate = self._condition_rate(category)
if rate is not None:
return '%s / day' % self._format_calculation_number(rate)
if category == 'demurrage':
legacy = self.demurrage_rate_o or self.demurrage_rate
if legacy is not None:
return '%s / day' % self._format_calculation_number(legacy)
return ''
def _calculation_pumping_rate_text(self):
rate = self._condition_rate('pumping')
if rate is not None:
return '%s MT/hour' % self._format_calculation_number(rate)
legacy = self.pumping_rate_o or self.pumping_rate
if legacy:
return '%s MT/hour' % self._format_calculation_number(legacy)
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._event_labels.get(
start_event, self._event_labels['notice_of_readiness'])
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()
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 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')