2305 lines
94 KiB
Python
Executable File
2305 lines
94 KiB
Python
Executable File
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||
# this repository contains the full copyright notices and license terms.
|
||
from trytond.model import fields
|
||
from trytond.pool import Pool, PoolMeta
|
||
from trytond.pyson import Bool, Eval, Id
|
||
from trytond.model import (ModelSQL, ModelView)
|
||
from trytond.tools import is_full_text, lstrip_wildcard
|
||
from trytond.transaction import Transaction, inactive_records
|
||
from decimal import getcontext, Decimal, ROUND_HALF_UP
|
||
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
|
||
from sql.conditionals import Case
|
||
from sql import Column, Literal
|
||
from sql.functions import CurrentTimestamp, DateTrunc
|
||
from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction
|
||
from itertools import chain, groupby
|
||
from operator import itemgetter
|
||
import datetime
|
||
from collections import defaultdict
|
||
from sql import Table
|
||
from trytond.modules.purchase_trade.service import ContractFactory
|
||
import requests
|
||
import io
|
||
import base64
|
||
import logging
|
||
import json
|
||
import re
|
||
import html
|
||
from trytond.exceptions import UserError
|
||
from trytond.modules.stock.shipment import SupplierShipping as BaseSupplierShipping
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class Location(metaclass=PoolMeta):
|
||
__name__ = 'stock.location'
|
||
|
||
def get_places(self):
|
||
t = Table('places')
|
||
cursor = Transaction().connection.cursor()
|
||
cursor.execute(*t.select(
|
||
t.PLACE_KEY,
|
||
where=t.PLACE_NAME.ilike(f'%{self.name}%')
|
||
))
|
||
rows = cursor.fetchall()
|
||
if rows:
|
||
return int(rows[0][0])
|
||
|
||
@classmethod
|
||
def getLocationByName(cls, location, type):
|
||
location = location.upper()
|
||
loc = cls.search([('name', '=', location),('type', '=', type)], limit=1)
|
||
if loc:
|
||
return loc[0].id
|
||
else:
|
||
loc = cls()
|
||
loc.name = location
|
||
loc.type = type
|
||
cls.save([loc])
|
||
return loc
|
||
|
||
@classmethod
|
||
def get_transit_id(cls):
|
||
return cls.getLocationByName('TRANSIT','storage')
|
||
|
||
def is_transit(self):
|
||
if self.name == 'Transit':
|
||
return True
|
||
|
||
class Move(metaclass=PoolMeta):
|
||
__name__ = 'stock.move'
|
||
|
||
bldate = fields.Date("BL date")
|
||
lotqt = fields.One2Many('lot.qt','lot_move',"Lots")
|
||
lot = fields.Many2One('lot.lot',"Lot")
|
||
|
||
def get_linked_transit_move(self):
|
||
if self.from_location.is_transit():
|
||
Move = Pool().get('stock.move')
|
||
Location = Pool().get('stock.location')
|
||
moves = Move.search([('lot','=',self.lot),('to_location','=',Location.get_transit_id())],order=[('id', 'DESC')],limit=1)
|
||
return moves[0] if moves else None
|
||
|
||
@classmethod
|
||
def validate(cls, moves):
|
||
super(Move, cls).validate(moves)
|
||
Lot = Pool().get('lot.lot')
|
||
LotMove = Pool().get('lot.move')
|
||
for move in moves:
|
||
if move.lot:
|
||
l = Lot(move.lot.id)
|
||
lm = LotMove.search([('lot','=',l.id),('move','=',move)])
|
||
if len(lm) == 0:
|
||
lm = LotMove.search(['lot','>',0],order=[('sequence','DESC')])
|
||
new_lm = LotMove()
|
||
new_lm.move = move.id
|
||
new_lm.lot = l.id
|
||
new_lm.sequence = 1
|
||
if lm:
|
||
new_lm.sequence = lm[0].sequence + 1
|
||
|
||
LotMove.save([new_lm])
|
||
|
||
class InvoiceLine(metaclass=PoolMeta):
|
||
__name__ = 'account.invoice.line'
|
||
|
||
lot = fields.Many2One('lot.lot',"Lot")
|
||
fee = fields.Many2One('fee.fee',"Fee")
|
||
|
||
@classmethod
|
||
def validate(cls, lines):
|
||
super(InvoiceLine, cls).validate(lines)
|
||
Lot = Pool().get('lot.lot')
|
||
for line in lines:
|
||
if line.lot and line.quantity > 0:
|
||
l = Lot(line.lot.id)
|
||
if line.description == 'Pro forma':
|
||
if line.invoice_type == 'in':
|
||
l.invoice_line_prov = line.id
|
||
l.lot_pur_inv_state = 'prov'
|
||
else:
|
||
l.sale_invoice_line_prov = line.id
|
||
l.lot_sale_inv_state = 'prov'
|
||
elif line.description == 'Final':
|
||
if line.invoice_type == 'in':
|
||
l.invoice_line = line.id
|
||
l.lot_pur_inv_state = 'invoiced'
|
||
else:
|
||
l.sale_invoice_line = line.id
|
||
l.lot_sale_inv_state = 'invoiced'
|
||
Lot.save([l])
|
||
|
||
class ShipmentInternal(metaclass=PoolMeta):
|
||
__name__ = 'stock.shipment.internal'
|
||
fees = fields.One2Many('fee.fee','shipment_internal',"Fees")
|
||
|
||
sof_json = [
|
||
{
|
||
"date": "2025-02-02",
|
||
"time": "19:00",
|
||
"event": "S/B FOR ARRIVAL - PROCEED TO CAK NO.2 ANCHORAGE",
|
||
"end_time": "2025-02-02 21:30",
|
||
"duration": {"iso_8601": "PT26H30M", "text": "2h 30m"},
|
||
},
|
||
{
|
||
"date": "2025-02-02",
|
||
"time": "21:30",
|
||
"event": "DROP ANCHOR AT CAK NO.2 ANCHOR / ARR. CAN NOR TENDERED",
|
||
"end_time": "2025-02-02 21:40",
|
||
"duration": {"iso_8601": "-PT23H50M", "text": "10m"},
|
||
},
|
||
{
|
||
"date": "2025-02-02",
|
||
"time": "21:40",
|
||
"event": "BROUGHT UP ANCHOR/F.W.E",
|
||
"end_time": "2025-02-03 07:20",
|
||
"duration": {"iso_8601": "PT9H40M", "text": "9h 40m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "07:20",
|
||
"event": "S.B.E. FOR SHIFTING",
|
||
"end_time": "2025-02-03 07:45",
|
||
"duration": {"iso_8601": "PT0H25M", "text": "25m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "07:45",
|
||
"event": "ANCHOR AWEIGHT",
|
||
"end_time": "2025-02-03 10:45",
|
||
"duration": {"iso_8601": "PT3H0M", "text": "3h"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "10:45",
|
||
"event": "SEA PILOT ON BOARD",
|
||
"end_time": "2025-02-03 14:00",
|
||
"duration": {"iso_8601": "PT3H15M", "text": "3h 15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "14:00",
|
||
"event": "EXCHANGED SEA PILOT TO RIVER PILOT",
|
||
"end_time": "2025-02-03 17:45",
|
||
"duration": {"iso_8601": "PT3H45M", "text": "3h 45m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "17:45",
|
||
"event": "DROP ANCHOR AT NANTONS DANGEROUS ANCHORAGE",
|
||
"end_time": "2025-02-03 18:00",
|
||
"duration": {"iso_8601": "PT0H15M", "text": "15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "18:00",
|
||
"event": "BROUGHT UP ANCHOR/F.W.E/PILOT OFF",
|
||
"end_time": "2025-02-04 06:30",
|
||
"duration": {"iso_8601": "PT12H30M", "text": "12h 30m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "06:30",
|
||
"event": "S.B.E. FOR BERTHING",
|
||
"end_time": "2025-02-04 07:10",
|
||
"duration": {"iso_8601": "PT0H40M", "text": "40m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "07:10",
|
||
"event": "PILOT ON BOARD",
|
||
"end_time": "2025-02-04 07:15",
|
||
"duration": {"iso_8601": "PT0H5M", "text": "5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "07:15",
|
||
"event": "ANCHOR AWEIGHT",
|
||
"end_time": "2025-02-04 09:20",
|
||
"duration": {"iso_8601": "PT2H5M", "text": "2h 5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "09:20",
|
||
"event": "FWD TUG MADE FAST",
|
||
"end_time": "2025-02-04 09:35",
|
||
"duration": {"iso_8601": "PT0H15M", "text": "15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "09:35",
|
||
"event": "FIRST LINE ASHORE",
|
||
"end_time": "2025-02-04 08:50",
|
||
"duration": {
|
||
"iso_8601": "-PT0H45M",
|
||
"text": "Incohérence temporelle",
|
||
"alert": "Vérifier l'heure de TUG OFF",
|
||
},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "08:50",
|
||
"event": "TUG OFF",
|
||
"end_time": "2025-02-04 09:55",
|
||
"duration": {"iso_8601": "PT1H5M", "text": "1h 5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "09:55",
|
||
"event": "ALL LINE MADE FAST/F.W.E / NOR ACCEPTED",
|
||
"end_time": "2025-02-04 10:15",
|
||
"duration": {"iso_8601": "PT0H20M", "text": "20m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "10:15",
|
||
"event": "GANGWAY DOWN/PILOT OFF",
|
||
"end_time": "2025-02-04 10:20",
|
||
"duration": {"iso_8601": "PT0H5M", "text": "5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "10:20",
|
||
"event": "AGENT / BAMBRIATION ON BOARD",
|
||
"end_time": "2025-02-04 11:10",
|
||
"duration": {"iso_8601": "PT0H50M", "text": "50m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:10",
|
||
"event": "FREE-PRACTIQUE QUANTED",
|
||
"end_time": "2025-02-04 11:40",
|
||
"duration": {"iso_8601": "PT0H30M", "text": "30m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:40",
|
||
"event": "SURVEYOR & LOADING MASTER ON BOARD",
|
||
"end_time": "2025-02-04 11:40",
|
||
"duration": {
|
||
"iso_8601": "PT0H0M",
|
||
"text": "0m",
|
||
"note": "Début de SAFETY MEETING",
|
||
},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:40",
|
||
"event": "SAFETY MEETING",
|
||
"end_time": "2025-02-04 12:20",
|
||
"duration": {"iso_8601": "PT0H40M", "text": "40m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:45",
|
||
"event": "TANK INSPECTION",
|
||
"end_time": "2025-02-04 12:30",
|
||
"duration": {"iso_8601": "PT0H45M", "text": "45m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "13:10",
|
||
"event": 'CARGO HOSE CONNECTED (3 x 8")',
|
||
"end_time": "2025-02-04 13:25",
|
||
"duration": {"iso_8601": "PT0H15M", "text": "15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "13:25",
|
||
"event": "LEAKAGE TESTED",
|
||
"end_time": "2025-02-04 13:45",
|
||
"duration": {"iso_8601": "PT0H20M", "text": "20m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "14:15",
|
||
"event": "COMMENCED LOADING SULPHURIC ACID",
|
||
"end_time": "2025-02-05 08:50",
|
||
"duration": {"iso_8601": "PT18H35M", "text": "18h 35m"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "08:50",
|
||
"event": "COMPLETED LOADING SULPHURIC ACID",
|
||
"end_time": "2025-02-05 09:00",
|
||
"duration": {"iso_8601": "PT0H10M", "text": "10m"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "08:50",
|
||
"event": "AIR BLOWING",
|
||
"end_time": "2025-02-05 09:00",
|
||
"duration": {"iso_8601": "PT0H10M", "text": "10m"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "09:00",
|
||
"event": "VILLAGING, SAMPLING AND CALCULATION",
|
||
"end_time": "2025-02-05 11:00",
|
||
"duration": {"iso_8601": "PT2H0M", "text": "2h"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "10:40",
|
||
"event": 'HOSE OFF (308")',
|
||
"end_time": "2025-02-05 11:40",
|
||
"duration": {"iso_8601": "PT1H0M", "text": "1h"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "11:40",
|
||
"event": "CARGO DOCUMENT COMPLETED",
|
||
"end_time": "2025-02-05 11:40",
|
||
"duration": {"iso_8601": "PT0H0M", "text": "Événement final"},
|
||
},
|
||
]
|
||
|
||
class ContainerType(ModelSQL, ModelView):
|
||
"Container Type"
|
||
__name__ = 'stock.container.type'
|
||
|
||
code = fields.Char('Code', required=True)
|
||
name = fields.Char('Description', required=True)
|
||
|
||
teu = fields.Numeric('TEU Factor', digits=(5, 2), required=True)
|
||
|
||
is_reefer = fields.Boolean('Reefer')
|
||
is_special = fields.Boolean('Special Equipment')
|
||
|
||
class ShipmentContainer(ModelSQL, ModelView):
|
||
"Shipment Container"
|
||
__name__ = 'stock.shipment.container'
|
||
|
||
shipment = fields.Many2One(
|
||
'stock.shipment.in', 'Shipment', required=True
|
||
)
|
||
|
||
container_type = fields.Many2One(
|
||
'stock.container.type', 'Container Type', required=True
|
||
)
|
||
|
||
container_no = fields.Char('Container Number')
|
||
quantity = fields.Integer('Quantity', required=True)
|
||
seal_no = fields.Char('Seal Number')
|
||
is_reefer = fields.Boolean('Reefer')
|
||
|
||
class ShipmentWR(ModelSQL,ModelView):
|
||
"Shipment WR"
|
||
__name__ = "shipment.wr"
|
||
shipment_in = fields.Many2One('stock.shipment.in',"Shipment In")
|
||
wr = fields.Many2One('weight.report',"WR")
|
||
|
||
class ShipmentIn(metaclass=PoolMeta):
|
||
__name__ = 'stock.shipment.in'
|
||
|
||
from_location = fields.Many2One('stock.location', 'From location')
|
||
to_location = fields.Many2One('stock.location', 'To location')
|
||
transport_type = fields.Selection([
|
||
('vessel', 'Vessel'),
|
||
('truck', 'Truck'),
|
||
('other', 'Other'),
|
||
], 'Transport type')
|
||
vessel = fields.Many2One('trade.vessel',"Vessel")
|
||
info = fields.Function(fields.Text("Info",states={'invisible': ~Eval('info',False)}),'get_info')
|
||
anim = fields.Function(fields.Text(""),'get_anim')
|
||
carte = fields.Function(fields.Text("",states={'invisible': ~Eval('info',False)}),'get_imo')
|
||
fees = fields.One2Many('fee.fee','shipment_in',"Fees")
|
||
lotqt = fields.One2Many('lot.qt','lot_shipment_in',"Lots")
|
||
quantity = fields.Function(fields.Numeric("Quantity"),'get_quantity')
|
||
unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit')
|
||
bl_date = fields.Date("BL date")
|
||
bl_number = fields.Char("BL number")
|
||
etl = fields.Date("Est. Loading")
|
||
eta = fields.Date("🏗️ETA POL")
|
||
etad = fields.Date("⚓ETA")
|
||
atad = fields.Date("Act. Destination")
|
||
etd = fields.Date("ETD")
|
||
unloaded = fields.Date("Act. Discharge")
|
||
booking = fields.Char("Booking Nb")
|
||
booking_date =fields.Date("Booking date")
|
||
ref = fields.Char("Our reference")
|
||
note = fields.Text("Notes")
|
||
dashboard = fields.Many2One('purchase.dashboard',"Dashboard")
|
||
himself = fields.Function(fields.Many2One('stock.shipment.in',"Shipment"),'get_sh')
|
||
sof = fields.One2Many('sof.statement', 'shipment',"Demurrage calculations")
|
||
del_from = fields.Date("Delivery period from")
|
||
del_to = fields.Date("to")
|
||
estimated_date = fields.One2Many('pricing.estimated','shipment_in',"Estimated date")
|
||
carrier_ = fields.Many2One('party.party',"Carrier")
|
||
start_date = fields.Date("Start date")
|
||
travel_nb = fields.Char("Travel nb")
|
||
receive_date = fields.Date("Reception date")
|
||
receive_nb = fields.Char("Reception nb")
|
||
cargo_mode = fields.Selection([
|
||
('bulk', 'Bulk'),
|
||
('container', 'Container'),
|
||
], 'Cargo Mode', required=True)
|
||
|
||
vessel_type = fields.Function(fields.Many2One('stock.vessel.type',"Vessel type"),'get_vessel_type')
|
||
container = fields.One2Many(
|
||
'stock.shipment.container',
|
||
'shipment',
|
||
'Container'
|
||
)
|
||
shipment_wr = fields.One2Many('shipment.wr','shipment_in',"WR")
|
||
controller = fields.Many2One('party.party',"Controller")
|
||
surveyor = fields.Many2One('party.party', "Surveyor")
|
||
controller_target = fields.Char("Targeted controller")
|
||
send_instruction = fields.Boolean("Send instruction")
|
||
instructions = fields.Text("Instructions")
|
||
add_bl = fields.Boolean("Add BL")
|
||
add_invoice = fields.Boolean("Add invoice")
|
||
returned_id = fields.Char("Returned ID")
|
||
result = fields.Char("Result",readonly=True)
|
||
agent = fields.Many2One('party.party',"Booking Agent")
|
||
service_order_key = fields.Integer("Service Order Key")
|
||
|
||
@classmethod
|
||
def __setup__(cls):
|
||
super().__setup__()
|
||
cls._buttons.update({
|
||
'compute': {},
|
||
'send': {},
|
||
})
|
||
|
||
def get_vessel_type(self,name=None):
|
||
if self.vessel:
|
||
return self.vessel.vessel_type
|
||
|
||
def _get_report_primary_move(self):
|
||
moves = list(self.incoming_moves or self.moves or [])
|
||
return moves[0] if moves else None
|
||
|
||
def _get_report_primary_lot(self):
|
||
move = self._get_report_primary_move()
|
||
return getattr(move, 'lot', None) if move else None
|
||
|
||
def _get_report_trade_line(self):
|
||
lot = self._get_report_primary_lot()
|
||
if not lot:
|
||
return None
|
||
return getattr(lot, 'sale_line', None) or getattr(lot, 'line', None)
|
||
|
||
def _get_report_insurance_fee(self):
|
||
for fee in self.fees or []:
|
||
product = getattr(fee, 'product', None)
|
||
name = ((getattr(product, 'name', '') or '')).strip().lower()
|
||
if 'insurance' in name:
|
||
return fee
|
||
return None
|
||
|
||
def _get_report_incoming_amount_data(self):
|
||
total = Decimal('0.0')
|
||
currency = None
|
||
for move in (self.incoming_moves or []):
|
||
move_amount, move_currency = self._get_report_incoming_move_amount(
|
||
move)
|
||
total += move_amount
|
||
if not currency and move_currency:
|
||
currency = move_currency
|
||
return total, currency
|
||
|
||
def _get_report_incoming_move_amount(self, move):
|
||
quantity = Decimal(str(getattr(move, 'quantity', 0) or 0))
|
||
unit_price = getattr(move, 'unit_price', None)
|
||
if unit_price not in (None, ''):
|
||
move_currency = getattr(move, 'currency', None)
|
||
return quantity * Decimal(str(unit_price or 0)), move_currency
|
||
|
||
lot = getattr(move, 'lot', None)
|
||
line = getattr(lot, 'line', None) if lot else None
|
||
if not lot or not line:
|
||
return Decimal('0.0'), None
|
||
|
||
lot_quantity = Decimal(str(
|
||
lot.get_current_quantity_converted() or 0))
|
||
line_unit_price = Decimal(str(getattr(line, 'unit_price', 0) or 0))
|
||
trade = getattr(line, 'purchase', None)
|
||
line_currency = getattr(trade, 'currency', None) if trade else None
|
||
return lot_quantity * line_unit_price, line_currency
|
||
|
||
@staticmethod
|
||
def _get_report_currency_text(currency):
|
||
return (
|
||
getattr(currency, 'rec_name', None)
|
||
or getattr(currency, 'code', None)
|
||
or getattr(currency, 'symbol', None)
|
||
or '')
|
||
|
||
@staticmethod
|
||
def _format_report_amount(value):
|
||
if value in (None, ''):
|
||
return ''
|
||
value = Decimal(str(value or 0)).quantize(Decimal('0.01'))
|
||
return format(value, 'f')
|
||
|
||
@staticmethod
|
||
def _format_report_quantity(value, digits='0.001'):
|
||
if value in (None, ''):
|
||
return ''
|
||
quantity = Decimal(str(value or 0)).quantize(Decimal(digits))
|
||
text = format(quantity, 'f')
|
||
return text.rstrip('0').rstrip('.') or '0'
|
||
|
||
def _get_report_trade(self):
|
||
line = self._get_report_trade_line()
|
||
if not line:
|
||
return None
|
||
return getattr(line, 'sale', None) or getattr(line, 'purchase', None)
|
||
|
||
def _get_report_weight_totals(self):
|
||
net = Decimal('0')
|
||
gross = Decimal('0')
|
||
for move in (self.incoming_moves or self.moves or []):
|
||
lot = getattr(move, 'lot', None)
|
||
if lot:
|
||
lot_net = (
|
||
lot.get_current_quantity()
|
||
if hasattr(lot, 'get_current_quantity')
|
||
else lot.get_current_quantity_converted()
|
||
if hasattr(lot, 'get_current_quantity_converted')
|
||
else getattr(move, 'quantity', 0)
|
||
)
|
||
lot_gross = (
|
||
lot.get_current_gross_quantity()
|
||
if hasattr(lot, 'get_current_gross_quantity')
|
||
else lot_net
|
||
)
|
||
net += Decimal(str(lot_net or 0))
|
||
gross += Decimal(str(lot_gross or 0))
|
||
else:
|
||
quantity = Decimal(str(getattr(move, 'quantity', 0) or 0))
|
||
net += quantity
|
||
gross += quantity
|
||
return net, gross
|
||
|
||
@property
|
||
def report_product_name(self):
|
||
line = self._get_report_trade_line()
|
||
product = getattr(line, 'product', None) if line else None
|
||
if product:
|
||
return product.name or ''
|
||
move = self._get_report_primary_move()
|
||
product = getattr(move, 'product', None) if move else None
|
||
return getattr(product, 'name', '') or ''
|
||
|
||
@property
|
||
def report_product_description(self):
|
||
line = self._get_report_trade_line()
|
||
product = getattr(line, 'product', None) if line else None
|
||
if product:
|
||
return product.description or ''
|
||
move = self._get_report_primary_move()
|
||
product = getattr(move, 'product', None) if move else None
|
||
return getattr(product, 'description', '') or ''
|
||
|
||
@property
|
||
def report_insurance_footer_ref(self):
|
||
return self.bl_number or self.number or ''
|
||
|
||
@property
|
||
def report_insurance_certificate_number(self):
|
||
return self.bl_number or self.number or ''
|
||
|
||
@property
|
||
def report_insurance_account_of(self):
|
||
line = self._get_report_trade_line()
|
||
trade = getattr(line, 'sale', None) or getattr(line, 'purchase', None)
|
||
party = getattr(trade, 'party', None) if trade else None
|
||
if party:
|
||
return party.rec_name or ''
|
||
return getattr(self.supplier, 'rec_name', '') or ''
|
||
|
||
@property
|
||
def report_insurance_goods_description(self):
|
||
name = self.report_product_name
|
||
description = self.report_product_description
|
||
if description and description != name:
|
||
return ' - '.join(part for part in [name, description] if part)
|
||
return name or description
|
||
|
||
@property
|
||
def report_insurance_loading_port(self):
|
||
return getattr(self.from_location, 'name', '') or ''
|
||
|
||
@property
|
||
def report_insurance_discharge_port(self):
|
||
return getattr(self.to_location, 'name', '') or ''
|
||
|
||
@property
|
||
def report_insurance_transport(self):
|
||
if self.vessel and self.vessel.vessel_name:
|
||
return self.vessel.vessel_name
|
||
return self.transport_type or ''
|
||
|
||
@property
|
||
def report_insurance_amount(self):
|
||
insured_amount, insured_currency = self._get_report_incoming_amount_data()
|
||
if insured_amount:
|
||
insured_amount *= Decimal('1.10')
|
||
currency_text = self._get_report_currency_text(insured_currency)
|
||
amount_text = self._format_report_amount(insured_amount)
|
||
return ' '.join(part for part in [currency_text, amount_text] if part)
|
||
|
||
fee = self._get_report_insurance_fee()
|
||
if not fee:
|
||
return ''
|
||
currency = getattr(fee, 'currency', None)
|
||
currency_text = self._get_report_currency_text(currency)
|
||
amount = self._format_report_amount(fee.get_amount())
|
||
return ' '.join(part for part in [currency_text, amount] if part)
|
||
|
||
@property
|
||
def report_insurance_incoming_amount(self):
|
||
amount, currency = self._get_report_incoming_amount_data()
|
||
currency_text = self._get_report_currency_text(currency)
|
||
amount_text = self._format_report_amount(amount)
|
||
return ' '.join(part for part in [currency_text, amount_text] if part)
|
||
|
||
@property
|
||
def report_insurance_amount_insured(self):
|
||
amount, currency = self._get_report_incoming_amount_data()
|
||
insured_amount = amount * Decimal('1.10')
|
||
currency_text = self._get_report_currency_text(currency)
|
||
amount_text = self._format_report_amount(insured_amount)
|
||
return ' '.join(part for part in [currency_text, amount_text] if part)
|
||
|
||
@property
|
||
def report_insurance_surveyor(self):
|
||
if self.surveyor:
|
||
return self.surveyor.rec_name or ''
|
||
if self.controller:
|
||
return self.controller.rec_name or ''
|
||
fee = self._get_report_insurance_fee()
|
||
supplier = getattr(fee, 'supplier', None) if fee else None
|
||
return getattr(supplier, 'rec_name', '') or ''
|
||
|
||
@property
|
||
def report_insurance_contact_surveyor(self):
|
||
return self.report_insurance_surveyor
|
||
|
||
@property
|
||
def report_insurance_issue_place_and_date(self):
|
||
Date = Pool().get('ir.date')
|
||
address = None
|
||
if self.company and getattr(self.company, 'party', None):
|
||
address = self.company.party.address_get()
|
||
place = (
|
||
getattr(address, 'city', None)
|
||
or getattr(self.company.party, 'rec_name', None)
|
||
if self.company and getattr(self.company, 'party', None) else ''
|
||
) or ''
|
||
today = Date.today()
|
||
date_text = today.strftime('%d-%m-%Y') if today else ''
|
||
return ', '.join(part for part in [place, date_text] if part)
|
||
|
||
@property
|
||
def report_packing_product_class(self):
|
||
return self.report_product_name
|
||
|
||
@property
|
||
def report_packing_contract_number(self):
|
||
trade = self._get_report_trade()
|
||
return (
|
||
getattr(trade, 'reference', None)
|
||
or getattr(trade, 'number', None)
|
||
or self.reference
|
||
or self.number
|
||
or '')
|
||
|
||
@property
|
||
def report_packing_invoice_qty(self):
|
||
quantity = self.quantity if self.quantity not in (None, '') else 0
|
||
return self._format_report_quantity(quantity)
|
||
|
||
@property
|
||
def report_packing_invoice_qty_unit(self):
|
||
unit = self.unit
|
||
return (
|
||
getattr(unit, 'symbol', None)
|
||
or getattr(unit, 'rec_name', None)
|
||
or '')
|
||
|
||
@property
|
||
def report_packing_origin(self):
|
||
trade = self._get_report_trade()
|
||
return (
|
||
getattr(trade, 'product_origin', None)
|
||
or getattr(self.from_location, 'name', None)
|
||
or '')
|
||
|
||
@property
|
||
def report_packing_product(self):
|
||
return self.report_product_name
|
||
|
||
@property
|
||
def report_packing_counterparty_name(self):
|
||
trade = self._get_report_trade()
|
||
party = getattr(trade, 'party', None) if trade else None
|
||
if party:
|
||
return party.rec_name or ''
|
||
return getattr(self.supplier, 'rec_name', '') or ''
|
||
|
||
@property
|
||
def report_packing_ship_name(self):
|
||
if self.vessel and self.vessel.vessel_name:
|
||
return self.vessel.vessel_name
|
||
return self.transport_type or ''
|
||
|
||
@property
|
||
def report_packing_loading_port(self):
|
||
return getattr(self.from_location, 'name', '') or ''
|
||
|
||
@property
|
||
def report_packing_destination_port(self):
|
||
return getattr(self.to_location, 'name', '') or ''
|
||
|
||
@property
|
||
def report_packing_chunk_number(self):
|
||
return self.bl_number or self.number or ''
|
||
|
||
@property
|
||
def report_packing_chunk_date(self):
|
||
if self.bl_date:
|
||
return self.bl_date.strftime('%d-%m-%Y')
|
||
return ''
|
||
|
||
@property
|
||
def report_packing_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)
|
||
|
||
def get_rec_name(self, name=None):
|
||
if self.number:
|
||
return self.number + '[' + (self.vessel.vessel_name if self.vessel else '') + (('-' + self.travel_nb) if self.travel_nb else '') + ']'
|
||
else:
|
||
return str(self.id)
|
||
|
||
def create_fee(self,controller):
|
||
Fee = Pool().get('fee.fee')
|
||
Product = Pool().get('product.product')
|
||
fee = Fee()
|
||
fee.shipment_in = self.id
|
||
fee.supplier = controller
|
||
fee.type = 'budgeted'
|
||
fee.p_r = 'pay'
|
||
price,mode,curr,unit = controller.get_sla_cost(self.to_location)
|
||
if price and mode and curr and unit:
|
||
fee.mode = mode
|
||
fee.currency = curr
|
||
fee.unit = unit
|
||
fee.quantity = self.get_bales() or 1
|
||
fee.product = Product.get_by_name('Reweighing')
|
||
fee.price = price
|
||
Fee.save([fee])
|
||
|
||
def get_controller(self):
|
||
ControllerCategory = Pool().get('party.category')
|
||
PartyCategory = Pool().get('party.party-party.category')
|
||
cc = ControllerCategory.search(['name','=','CONTROLLER'])
|
||
if cc:
|
||
cc = cc[0]
|
||
controllers = PartyCategory.search(['category','=',cc.id])
|
||
prioritized = []
|
||
for c in controllers:
|
||
if not c.party.IsAvailableForControl(self):
|
||
continue
|
||
gap, rule = c.party.get_controller_execution_priority(self)
|
||
prioritized.append((
|
||
1 if rule else 0,
|
||
gap if gap is not None else Decimal('-999999'),
|
||
c.party,
|
||
))
|
||
if prioritized:
|
||
prioritized.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||
return prioritized[0][2]
|
||
|
||
def get_instructions_html(self,inv_date,inv_nb):
|
||
vessel = self.vessel.vessel_name if self.vessel else ""
|
||
lines = [
|
||
"<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)
|
||
sale_line.unit_price = sale_line.get_price_linked_currency()
|
||
else:
|
||
sale.currency = Currency.get_by_name(sell_price_currency)
|
||
sale_line.unit_price = round(sell_price,4)
|
||
sale_line.unit = Uom.get_by_name(sell_price_unit)
|
||
sale_line.premium = premium
|
||
Sale.save([sale])
|
||
sale_line.sale = sale.id
|
||
sale_line.quantity = round(lot_net_weight,2)
|
||
sale_line.quantity_theorical = round(lot_net_weight,2)
|
||
sale_line.product = Product.get_by_name('BRAZIL COTTON')
|
||
logger.info("PRODUCT:%s",sale_line.product)
|
||
sale_line.unit = Uom.get_by_name(lot_unit)
|
||
sale_line.price_type = 'priced'
|
||
sale_line.created_by_code = False
|
||
sale_line.note = dec_key
|
||
SaleLine.save([sale_line])
|
||
|
||
#need to link the virtual part to the shipment
|
||
lqt = LotQt.search([('lot_s','=',sale_line.lots[0])])
|
||
if lqt:
|
||
lqt[0].lot_shipment_in = self
|
||
LotQt.save(lqt)
|
||
logger.info("SALE_LINKED_TO_SHIPMENT:%s",self)
|
||
|
||
ContractStart = Pool().get('contracts.start')
|
||
ContractDetail = Pool().get('contract.detail')
|
||
ct = ContractStart()
|
||
d = ContractDetail()
|
||
ct.type = 'Purchase'
|
||
ct.matched = True
|
||
ct.shipment_in = self
|
||
ct.lot = sale_line.lots[0]
|
||
ct.product = sale_line.product
|
||
ct.unit = sale_line.unit
|
||
d.party = Party.getPartyByName('FAIRCOT')
|
||
if sale_line.enable_linked_currency:
|
||
d.currency_unit = str(sale_line.linked_currency.id) + '_' + str(sale_line.linked_unit.id)
|
||
else:
|
||
d.currency_unit = str(sale.currency.id) + '_' + str(sale_line.unit.id)
|
||
d.quantity = sale_line.quantity
|
||
d.unit = sale_line.unit
|
||
d.price = sale_line.unit_price
|
||
d.price_type = 'priced'
|
||
d.crop = None
|
||
d.tol_min = 0
|
||
d.tol_max = 0
|
||
d.incoterm = None
|
||
d.reference = str(sale.id)
|
||
d.from_location = sale.from_location
|
||
d.to_location = sale.to_location
|
||
d.del_period = None
|
||
d.from_del = None
|
||
d.to_del = None
|
||
d.payment_term = sale.payment_term
|
||
ct.contracts = [d]
|
||
ContractFactory.create_contracts(
|
||
ct.contracts,
|
||
type_=ct.type,
|
||
ct=ct,
|
||
)
|
||
|
||
#Lots creation
|
||
vlot = sale_line.lots[0]
|
||
lqt = LotQt.search([('lot_s','=',vlot.id),('lot_p','>',0)])
|
||
if lqt and vlot.lot_quantity > 0:
|
||
lqt = lqt[0]
|
||
l = LotAdd()
|
||
l.lot_qt = lot_bales
|
||
l.lot_unit = Uom.get_by_name('bale')
|
||
l.lot_unit_line = Uom.get_by_name(lot_unit)
|
||
l.lot_quantity = round(lot_net_weight,2)
|
||
l.lot_gross_quantity = round(lot_gross_weight,2)
|
||
l.lot_premium = premium
|
||
l.lot_chunk_key = int(chunk_key)
|
||
logger.info("ADD_LOT:%s",int(chunk_key))
|
||
LotQt.add_physical_lots(lqt,[l])
|
||
|
||
return inv_date,inv_nb
|
||
|
||
def html_to_text(self,html_content):
|
||
text = re.sub(r"<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:
|
||
if s.laytime_clause_hour:
|
||
t = s.laytime_clause_hour # instance de datetime.time
|
||
delta = datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second)
|
||
s.laytime_commenced = s.notice_of_readiness_time + delta
|
||
s.laytime_allowed = round(s.quantity / s.pumping_rate_o,2)
|
||
s.laytime_completed = s.hoses_disconnected
|
||
#s.laytime_completed = s.laytime_commenced + datetime.timedelta(hours=s.laytime_allowed)
|
||
total_time = (s.laytime_completed - s.laytime_commenced).total_seconds() / 3600.0
|
||
logger.info("COMPUTE_DEDUCTION3:%s",total_time)
|
||
s.actual_time_used = round(total_time,2)
|
||
s.deductions = round(sum([e.duration for e in s.sof_events if e.deductible == True]),2)
|
||
effective_time = round(total_time - s.deductions,2)
|
||
s.laytime_used = effective_time
|
||
s.laytime_balance = round(s.laytime_allowed - s.laytime_used,2)
|
||
penalty = Decimal(s.laytime_balance) * (s.demurrage_rate_o / 24)
|
||
s.compensation_amount = round(penalty, 2)
|
||
Sof.save([s])
|
||
|
||
def get_vessel_url(self):
|
||
return f"https://www.vesselfinder.com/en/vessels/VOS-TRAVELLER-IMO-{self.vessel.vessel_imo}"
|
||
#return URL(f"https://www.vesselfinder.com/en/vessels/VOS-TRAVELLER-IMO-{self.vessel.vessel_imo}", target='new')
|
||
|
||
def get_imo(self,name):
|
||
if self.vessel:
|
||
return f"imo:{self.vessel.vessel_imo}"
|
||
|
||
def get_sh(self, name):
|
||
return self.id
|
||
|
||
@classmethod
|
||
def default_transport_type(cls):
|
||
return 'vessel'
|
||
|
||
@classmethod
|
||
def default_dashboard(cls):
|
||
return 1
|
||
|
||
def get_chunk_key(self):
|
||
keys = [m.lot.lot_chunk_key for m in self.incoming_moves if m.lot]
|
||
return ",".join(map(str, keys)) if keys else None
|
||
|
||
def get_quantity(self,name=None):
|
||
if self.incoming_moves:
|
||
return sum([(e.quantity if e.quantity else 0) for e in self.incoming_moves])
|
||
|
||
def get_bales(self,name=None):
|
||
Lot = Pool().get('lot.lot')
|
||
lots = Lot.search(['lot_shipment_in','=',self.id])
|
||
if lots:
|
||
return sum([l.lot_qt for l in lots])
|
||
|
||
def get_unit(self,name=None):
|
||
if self.incoming_moves:
|
||
return self.incoming_moves[0].unit
|
||
|
||
def get_info(self,name):
|
||
if self.vessel:
|
||
vessel = Pool().get('trade.vessel')(self.vessel)
|
||
return vessel.get_info()
|
||
|
||
def get_anim(self,name):
|
||
gif_url = "http://vps107.geneva.hosting:8000/images/tanker.gif"
|
||
anim = f'<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'
|
||
|
||
# === A. Informations générales ===
|
||
shipment = fields.Many2One('stock.shipment.in', 'Shipment', required=True, ondelete='CASCADE')
|
||
loading_sequence = fields.Char('Loading Sequence') # ex: "Lot A - Supplier X"
|
||
|
||
# === Termes contractuels ===
|
||
laytime_allowed = fields.Float('Laytime Allowed (hours)')
|
||
notice_of_readiness_time = fields.DateTime('Notice of Readiness Time')
|
||
|
||
laytime_type = fields.Selection([
|
||
('nor', 'NOR +'),
|
||
(None, 'Laytime start'),
|
||
], 'Laytime clause')
|
||
laytime_clause_hour = fields.Time("")
|
||
laytime_start = fields.DateTime('Laytime Start')
|
||
laytime_end = fields.DateTime('Laytime End')
|
||
demurrage_rate = fields.Numeric('Demurrage Rate ($/day)', digits=(16, 2))
|
||
pumping_rate = fields.Float('Pumping Rate (MT/hour)')
|
||
|
||
laytime_type_o = fields.Selection([
|
||
('nor', 'NOR +'),
|
||
(None, 'Laytime start'),
|
||
], 'Laytime clause')
|
||
laytime_clause_hour_o = fields.Time("")
|
||
laytime_start_o = fields.DateTime('Laytime Start')
|
||
laytime_end_o = fields.DateTime('Laytime End')
|
||
demurrage_rate_o = fields.Numeric('Demurrage Rate ($/day)', digits=(16, 2))
|
||
pumping_rate_o = fields.Float('Pumping Rate (MT/hour)')
|
||
|
||
timebar_day = fields.Integer("TimeBar")
|
||
timebar_warn = fields.Integer("Warning TimeBar")
|
||
chart = fields.Many2One('document.incoming',"Charter Party Terms")
|
||
sof = fields.Many2One('document.incoming',"Statement of facts")
|
||
|
||
# === B. Événements du SOF ===
|
||
sof_events = fields.One2Many('sof.event', 'statement', "SOF Events")
|
||
|
||
arrival_time = fields.DateTime('Arrival Time')
|
||
hoses_connected = fields.DateTime('Hoses Connected')
|
||
start_pumping = fields.DateTime('Start Pumping')
|
||
end_pumping = fields.DateTime('End Pumping')
|
||
hoses_disconnected = fields.DateTime('Hoses Disconnected')
|
||
sailing_time = fields.DateTime('Sailing Time')
|
||
|
||
# === C. Résultats du calcul ===
|
||
laytime_commenced = fields.DateTime('Laytime Commenced')
|
||
laytime_completed = fields.DateTime('Laytime Completed')
|
||
actual_time_used = fields.Float('Actual Time Used (hours)', readonly=True)
|
||
deductions = fields.Float('Deductions (hours)', readonly=True)
|
||
laytime_used = fields.Float('Laytime Used (hours)', readonly=True)
|
||
laytime_balance = fields.Float('Laytime Balance (hours)', readonly=True)
|
||
compensation_type = fields.Selection([
|
||
('demurrage', 'Demurrage'),
|
||
('despatch', 'Despatch'),
|
||
(None, 'No Compensation'),
|
||
], 'Compensation Type')
|
||
compensation_amount = fields.Numeric('Compensation Amount ($)', readonly=True, digits=(16, 2))
|
||
quantity = fields.Function(fields.Float("Quantity loaded"),'get_qt')
|
||
|
||
notes = fields.Text('Remarks / Notes')
|
||
|
||
def get_qt(self,name):
|
||
if self.shipment.incoming_moves:
|
||
return sum([e.quantity for e in self.shipment.incoming_moves])
|
||
|
||
class SoFEvent(ModelSQL, ModelView):
|
||
"Event from Statement of Facts"
|
||
__name__ = 'sof.event'
|
||
|
||
statement = fields.Many2One('sof.statement', "Statement", ondelete='CASCADE')
|
||
|
||
start = fields.DateTime("Start Time")
|
||
end = fields.DateTime("End Time")
|
||
|
||
start_date = fields.Date("Start Date")
|
||
start_time = fields.Time("Start Time (H)")
|
||
end_date = fields.Date("End Date")
|
||
end_time = fields.Time("End Time (H)")
|
||
|
||
duration = fields.Float("Duration")
|
||
description = fields.Char("Description")
|
||
deductible = fields.Boolean("Deduct from Laytime")
|
||
|
||
class ImportSoFStart(ModelView):
|
||
"""Wizard Start View"""
|
||
__name__ = 'import.s'
|
||
|
||
pdf = fields.Binary('Statement of Facts PDF', required=True, filename='filename')
|
||
filename = fields.Char('SoF Filename',readonly=True)
|
||
charter = fields.Binary('Charter Party File', required=True, filename='charter_name')
|
||
charter_name = fields.Char('Charter Filename',readonly=True)
|
||
|
||
class ImportSoFWizard(Wizard):
|
||
"Import Statement of Facts Wizard"
|
||
__name__ = 'sof.import'
|
||
|
||
start = StateTransition()
|
||
|
||
sof = StateView('import.s', 'purchase_trade.view_sof_import_start', [
|
||
Button('Cancel', 'end', 'tryton-cancel'),
|
||
Button('Import', 'processing', 'tryton-ok')
|
||
])
|
||
|
||
processing = StateTransition()
|
||
|
||
def transition_start(self):
|
||
return 'sof'
|
||
|
||
def parse_pdf_text(self, binary_data):
|
||
text = ""
|
||
with io.BytesIO(binary_data) as pdf_file:
|
||
reader = PyPDF2.PdfReader(pdf_file)
|
||
for page in reader.pages:
|
||
text += page.extract_text()
|
||
return text
|
||
|
||
def parse_charter_text(self, binary_data):
|
||
try:
|
||
return binary_data.decode('utf-8')
|
||
except Exception:
|
||
return ''
|
||
|
||
def extract_fields_from_text(self,text):
|
||
def extract(pattern):
|
||
match = re.search(pattern, text)
|
||
return match.group(1).strip() if match else None
|
||
|
||
vessel = extract(r"Vessel Name:\s*(.+)")
|
||
port = extract(r"Port:\s*(.+)")
|
||
berth = extract(r"Berth:\s*(.+)")
|
||
cargo = extract(r"Cargo:\s*(.+)")
|
||
charter_ref = extract(r"Charter Party Ref:\s*(.+)")
|
||
laytime_allowed = extract(r"Laytime Allowed:\s*(\d+)\s*hours")
|
||
demurrage = extract(r"Demurrage Rate:\s*\$(\d[\d,]*)/day")
|
||
despatch = extract(r"Despatch Rate:\s*\$(\d[\d,]*)/day")
|
||
laytime_start_raw = extract(r"Laytime Start:\s*([\d\-: ]+)")
|
||
laytime_end_raw = extract(r"Laytime End:\s*([\d\-: ]+)")
|
||
quantity_raw = extract(r"Quantity:\s*([\d,]+)\s*MT")
|
||
quantity = float(quantity_raw.replace(',', '')) if quantity_raw else None
|
||
|
||
laytime_start = datetime.datetime.strptime(laytime_start_raw, "%Y-%m-%d %H:%M").date() if laytime_start_raw else None
|
||
laytime_end = datetime.datetime.strptime(laytime_end_raw, "%Y-%m-%d %H:%M").date() if laytime_end_raw else None
|
||
|
||
return {
|
||
'vessel_name': vessel,
|
||
'port': port,
|
||
'berth': berth,
|
||
'cargo': cargo,
|
||
'quantity': quantity,
|
||
'charter_ref': charter_ref,
|
||
'laytime_allowed': float(laytime_allowed) if laytime_allowed else None,
|
||
'laytime_start': laytime_start,
|
||
'laytime_end': laytime_end,
|
||
'demurrage_rate': float(demurrage.replace(',', '')) if demurrage else None,
|
||
'despatch_rate': float(despatch.replace(',', '')) if despatch else None,
|
||
}
|
||
|
||
def extract_sof_events(self, text):
|
||
"""
|
||
Extrait les événements datés à partir du texte brut du SOF PDF.
|
||
Format attendu (comme dans ton fichier) :
|
||
'15.05.2024 13:00 - 15.05.2024 18:00 Loading stopped due to rain'
|
||
"""
|
||
pattern = re.compile(
|
||
r'(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2})\s*-\s*(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2})\s+(.+)',
|
||
re.MULTILINE
|
||
)
|
||
events = []
|
||
logger.info("TEXT FOR EVENTS:\n%s", text)
|
||
matchs = pattern.finditer(text)
|
||
logger.info("MATCHS:%s",matchs)
|
||
for match in matchs:
|
||
logger.debug("MATCH: %s | %s -> %s", match.group(5), match.group(1), match.group(3))
|
||
start = datetime.datetime.strptime(f"{match.group(1)} {match.group(2)}", "%d.%m.%Y %H:%M")
|
||
end = datetime.datetime.strptime(f"{match.group(3)} {match.group(4)}", "%d.%m.%Y %H:%M")
|
||
description = match.group(5).strip()
|
||
duration = (end - start).total_seconds() / 3600
|
||
deductible = any(word in description.lower() for word in ['rain', 'waiting', 'no loading'])
|
||
events.append({
|
||
'start': start,
|
||
'end': end,
|
||
'duration': duration,
|
||
'description': description,
|
||
'deductible': deductible,
|
||
})
|
||
return events
|
||
|
||
def extract_sof_data_and_events(self, text):
|
||
def extract(pattern, fmt=None, default=None):
|
||
match = re.search(pattern, text, re.IGNORECASE)
|
||
if not match:
|
||
logger.warning("Pattern not found: %s", pattern)
|
||
return default
|
||
raw = match.group(1).strip()
|
||
try:
|
||
return datetime.datetime.strptime(raw, fmt) if fmt else raw
|
||
except Exception:
|
||
logger.error("Failed to parse date: %s", raw)
|
||
return default
|
||
|
||
# Nouvelles regex plus souples
|
||
arrival = extract(r"vessel.*arrived.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")
|
||
nor = extract(r"nor.*tendered.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")
|
||
start_pump = extract(r"start.*pumping.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")
|
||
end_pump = extract(r"end.*pumping.*?(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})", "%d.%m.%Y %H:%M")
|
||
|
||
logger.info("ARRIVAL: %s", arrival)
|
||
logger.info("NOR: %s", nor)
|
||
logger.info("START_PUMP: %s", start_pump)
|
||
logger.info("END_PUMP: %s", end_pump)
|
||
|
||
events = self.extract_sof_events(text)
|
||
|
||
return {
|
||
'arrival_time': arrival,
|
||
'notice_of_readiness_time': nor,
|
||
'start_pumping': start_pump,
|
||
'end_pumping': end_pump,
|
||
'hoses_connected': None,
|
||
'hoses_disconnected': None,
|
||
'sailing_time': None,
|
||
'events': events,
|
||
}
|
||
|
||
def extract_sof_data_and_events_from_json(self, sof_json):
|
||
def parse_datetime(date_str, time_str):
|
||
return datetime.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
|
||
|
||
def find_first_event(keywords):
|
||
for item in sof_json:
|
||
if any(kw.lower() in item['event'].lower() for kw in keywords):
|
||
return parse_datetime(item['date'], item['time'])
|
||
return None
|
||
|
||
def find_last_event(keywords):
|
||
for item in reversed(sof_json):
|
||
if any(kw.lower() in item['event'].lower() for kw in keywords):
|
||
return parse_datetime(item['date'], item['time'])
|
||
return None
|
||
|
||
events = []
|
||
for item in sof_json:
|
||
try:
|
||
start = parse_datetime(item['date'], item['time'])
|
||
end_str = item.get("end_time")
|
||
end = datetime.datetime.strptime(end_str, "%Y-%m-%d %H:%M") if end_str else None
|
||
|
||
description = item["event"]
|
||
duration = (end - start).total_seconds() / 3600 if end else 0
|
||
deductible = any(word in description.lower() for word in ['rain', 'waiting', 'no loading'])
|
||
|
||
events.append({
|
||
'start': start,
|
||
'start_date': start.date(),
|
||
'start_time': start.time(),
|
||
'end': end,
|
||
'end_date': end.date() if end else None,
|
||
'end_time': end.time() if end else None,
|
||
'duration': duration,
|
||
'description': description,
|
||
'deductible': deductible,
|
||
})
|
||
except Exception as e:
|
||
logger.error("Error parsing SOF event: %s | %s", item, e)
|
||
|
||
# Extraction par mots-clés (inchangé)
|
||
arrival = find_first_event(["arrived", "anchor at", "proceed to anchorage"])
|
||
nor = find_first_event(["nor tendered", "nor accepted"])
|
||
start_pump = find_first_event(["start pumping", "commenced loading"])
|
||
end_pump = find_first_event(["end pumping", "completed loading"])
|
||
hoses_connected = find_first_event(["hose connected"])
|
||
hoses_disconnected = find_first_event(["hose off", "hose disconnected"])
|
||
sailing_time = find_first_event(["sailing", "sb for sailing", "sea pilot on board"])
|
||
|
||
return {
|
||
'arrival_time': arrival,
|
||
'notice_of_readiness_time': nor,
|
||
'start_pumping': start_pump,
|
||
'end_pumping': end_pump,
|
||
'hoses_connected': hoses_connected,
|
||
'hoses_disconnected': hoses_disconnected,
|
||
'sailing_time': sailing_time,
|
||
'events': events,
|
||
}
|
||
|
||
def transition_processing(self):
|
||
pool = Pool()
|
||
Statement = pool.get("sof.statement")
|
||
SoFEvent = pool.get("sof.event")
|
||
|
||
# Lecture des documents
|
||
# text_sof = self.parse_pdf_text(self.sof.pdf)
|
||
# logger.info("SOF TEXT:\n%s", text_sof)
|
||
text_charter = self.parse_charter_text(self.sof.charter)
|
||
|
||
# Extraction des champs
|
||
# extracted_main = self.extract_fields_from_text(text_sof)
|
||
# extracted_sof = self.extract_sof_data_and_events(text_sof)
|
||
sof_json = [
|
||
{
|
||
"date": "2025-02-02",
|
||
"time": "19:00",
|
||
"event": "S/B FOR ARRIVAL - PROCEED TO CAK NO.2 ANCHORAGE",
|
||
"end_time": "2025-02-03 21:30",
|
||
"duration": {"iso_8601": "PT26H30M", "text": "26h 30m"},
|
||
},
|
||
{
|
||
"date": "2025-02-02",
|
||
"time": "21:30",
|
||
"event": "DROP ANCHOR AT CAK NO.2 ANCHOR / ARR. CAN NOR TENDERED",
|
||
"end_time": "2025-02-02 21:40",
|
||
"duration": {"iso_8601": "-PT23H50M", "text": "10m"},
|
||
},
|
||
{
|
||
"date": "2025-02-02",
|
||
"time": "21:40",
|
||
"event": "BROUGHT UP ANCHOR/F.W.E",
|
||
"end_time": "2025-02-03 07:20",
|
||
"duration": {"iso_8601": "PT9H40M", "text": "9h 40m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "07:20",
|
||
"event": "S.B.E. FOR SHIFTING",
|
||
"end_time": "2025-02-03 07:45",
|
||
"duration": {"iso_8601": "PT0H25M", "text": "25m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "07:45",
|
||
"event": "ANCHOR AWEIGHT",
|
||
"end_time": "2025-02-03 10:45",
|
||
"duration": {"iso_8601": "PT3H0M", "text": "3h"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "10:45",
|
||
"event": "SEA PILOT ON BOARD",
|
||
"end_time": "2025-02-03 14:00",
|
||
"duration": {"iso_8601": "PT3H15M", "text": "3h 15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "14:00",
|
||
"event": "EXCHANGED SEA PILOT TO RIVER PILOT",
|
||
"end_time": "2025-02-03 17:45",
|
||
"duration": {"iso_8601": "PT3H45M", "text": "3h 45m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "17:45",
|
||
"event": "DROP ANCHOR AT NANTONS DANGEROUS ANCHORAGE",
|
||
"end_time": "2025-02-03 18:00",
|
||
"duration": {"iso_8601": "PT0H15M", "text": "15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-03",
|
||
"time": "18:00",
|
||
"event": "BROUGHT UP ANCHOR/F.W.E/PILOT OFF",
|
||
"end_time": "2025-02-04 06:30",
|
||
"duration": {"iso_8601": "PT12H30M", "text": "12h 30m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "06:30",
|
||
"event": "S.B.E. FOR BERTHING",
|
||
"end_time": "2025-02-04 07:10",
|
||
"duration": {"iso_8601": "PT0H40M", "text": "40m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "07:10",
|
||
"event": "PILOT ON BOARD",
|
||
"end_time": "2025-02-04 07:15",
|
||
"duration": {"iso_8601": "PT0H5M", "text": "5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "07:15",
|
||
"event": "ANCHOR AWEIGHT",
|
||
"end_time": "2025-02-04 09:20",
|
||
"duration": {"iso_8601": "PT2H5M", "text": "2h 5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "09:20",
|
||
"event": "FWD TUG MADE FAST",
|
||
"end_time": "2025-02-04 09:35",
|
||
"duration": {"iso_8601": "PT0H15M", "text": "15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "09:35",
|
||
"event": "FIRST LINE ASHORE",
|
||
"end_time": "2025-02-04 08:50",
|
||
"duration": {
|
||
"iso_8601": "-PT0H45M",
|
||
"text": "Incohérence temporelle",
|
||
"alert": "Vérifier l'heure de TUG OFF",
|
||
},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "08:50",
|
||
"event": "TUG OFF",
|
||
"end_time": "2025-02-04 09:55",
|
||
"duration": {"iso_8601": "PT1H5M", "text": "1h 5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "09:55",
|
||
"event": "ALL LINE MADE FAST/F.W.E / NOR ACCEPTED",
|
||
"end_time": "2025-02-04 10:15",
|
||
"duration": {"iso_8601": "PT0H20M", "text": "20m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "10:15",
|
||
"event": "GANGWAY DOWN/PILOT OFF",
|
||
"end_time": "2025-02-04 10:20",
|
||
"duration": {"iso_8601": "PT0H5M", "text": "5m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "10:20",
|
||
"event": "AGENT / BAMBRIATION ON BOARD",
|
||
"end_time": "2025-02-04 11:10",
|
||
"duration": {"iso_8601": "PT0H50M", "text": "50m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:10",
|
||
"event": "FREE-PRACTIQUE QUANTED",
|
||
"end_time": "2025-02-04 11:40",
|
||
"duration": {"iso_8601": "PT0H30M", "text": "30m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:40",
|
||
"event": "SURVEYOR & LOADING MASTER ON BOARD",
|
||
"end_time": "2025-02-04 11:40",
|
||
"duration": {
|
||
"iso_8601": "PT0H0M",
|
||
"text": "0m",
|
||
"note": "Début de SAFETY MEETING",
|
||
},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:40",
|
||
"event": "SAFETY MEETING",
|
||
"end_time": "2025-02-04 12:20",
|
||
"duration": {"iso_8601": "PT0H40M", "text": "40m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "11:45",
|
||
"event": "TANK INSPECTION",
|
||
"end_time": "2025-02-04 12:30",
|
||
"duration": {"iso_8601": "PT0H45M", "text": "45m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "13:10",
|
||
"event": 'CARGO HOSE CONNECTED (3 x 8")',
|
||
"end_time": "2025-02-04 13:25",
|
||
"duration": {"iso_8601": "PT0H15M", "text": "15m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "13:25",
|
||
"event": "LEAKAGE TESTED",
|
||
"end_time": "2025-02-04 13:45",
|
||
"duration": {"iso_8601": "PT0H20M", "text": "20m"},
|
||
},
|
||
{
|
||
"date": "2025-02-04",
|
||
"time": "14:15",
|
||
"event": "COMMENCED LOADING SULPHURIC ACID",
|
||
"end_time": "2025-02-05 08:50",
|
||
"duration": {"iso_8601": "PT18H35M", "text": "18h 35m"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "08:50",
|
||
"event": "COMPLETED LOADING SULPHURIC ACID",
|
||
"end_time": "2025-02-05 09:00",
|
||
"duration": {"iso_8601": "PT0H10M", "text": "10m"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "08:50",
|
||
"event": "AIR BLOWING",
|
||
"end_time": "2025-02-05 09:00",
|
||
"duration": {"iso_8601": "PT0H10M", "text": "10m"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "09:00",
|
||
"event": "VILLAGING, SAMPLING AND CALCULATION",
|
||
"end_time": "2025-02-05 11:00",
|
||
"duration": {"iso_8601": "PT2H0M", "text": "2h"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "10:40",
|
||
"event": 'HOSE OFF (308")',
|
||
"end_time": "2025-02-05 11:40",
|
||
"duration": {"iso_8601": "PT1H0M", "text": "1h"},
|
||
},
|
||
{
|
||
"date": "2025-02-05",
|
||
"time": "11:40",
|
||
"event": "CARGO DOCUMENT COMPLETED",
|
||
"end_time": "2025-02-05 11:40",
|
||
"duration": {"iso_8601": "PT0H0M", "text": "Événement final"},
|
||
},
|
||
]
|
||
extracted_sof = self.extract_sof_data_and_events_from_json(sof_json)
|
||
|
||
# Création du Statement principal
|
||
statement = Statement.create(
|
||
[
|
||
{
|
||
"shipment": self.records[0].id,
|
||
"document": self.sof.pdf,
|
||
"document_name": self.sof.filename,
|
||
"charter_text": text_charter,
|
||
# **extracted_main,
|
||
**{k: v for k, v in extracted_sof.items() if k != "events"},
|
||
}
|
||
]
|
||
)[0]
|
||
|
||
# Création des événements liés
|
||
for e in extracted_sof["events"]:
|
||
SoFEvent.create(
|
||
[
|
||
{
|
||
"statement": statement.id,
|
||
"start": e["start"],
|
||
"start_date": e["start_date"],
|
||
"start_time": e["start_time"],
|
||
"end": e["end"],
|
||
"end_date": e["end_date"],
|
||
"end_time": e["end_time"],
|
||
"duration": e["duration"],
|
||
"description": e["description"],
|
||
"deductible": e["deductible"],
|
||
}
|
||
]
|
||
)
|
||
|
||
return "end"
|
||
|
||
class SofUpdate(Wizard):
|
||
"Update shipment"
|
||
__name__ = "sof.update"
|
||
|
||
start = StateTransition()
|
||
|
||
def transition_start(self):
|
||
Shipment = Pool().get('stock.shipment.in')
|
||
Sof = Pool().get('sof.statement')
|
||
for r in self.records:
|
||
sh = Shipment(r.id)
|
||
sof = Sof()
|
||
sof.chart = 6
|
||
sof.sof = 5
|
||
sh.sof = [sof]
|
||
Shipment.save([sh])
|
||
|
||
return 'end'
|
||
|
||
class AccountMoveLine(metaclass=PoolMeta):
|
||
"Account move line"
|
||
__name__ = 'account.move.line'
|
||
revaluate = fields.Integer("Linked line")
|
||
lot = fields.Many2One('lot.lot',"Lot")
|
||
fee = fields.Many2One('fee.fee',"Fee")
|
||
|
||
class AccountMove(metaclass=PoolMeta):
|
||
"Account move"
|
||
__name__ = 'account.move'
|
||
|
||
def IsBankMove(self):
|
||
PM = Pool().get('account.invoice.payment.method')
|
||
if self.lines:
|
||
for m in self.lines:
|
||
pm = PM.search(['debit_account','=',m.account])
|
||
if pm:
|
||
return True
|
||
else:
|
||
pm = PM.search(['credit_account','=',m.account])
|
||
if pm:
|
||
return True
|
||
return False
|
||
|
||
class Account(metaclass=PoolMeta):
|
||
"Account"
|
||
__name__ = 'account.account'
|
||
|
||
def get_foreign_currencies(self):
|
||
Currency = Pool().get('currency.currency')
|
||
cursor = Transaction().connection.cursor()
|
||
cursor.execute("""
|
||
SELECT DISTINCT second_currency
|
||
FROM account_move_line
|
||
WHERE account = %s
|
||
AND second_currency IS NOT NULL
|
||
""", [self.id])
|
||
|
||
company_currency = self.company.currency
|
||
currencies = []
|
||
for (currency_id,) in cursor.fetchall():
|
||
cur = Currency(currency_id)
|
||
if cur != company_currency:
|
||
currencies.append(cur)
|
||
|
||
return currencies
|
||
|
||
def get_lines_curr(self,curr):
|
||
MoveLine = Pool().get('account.move.line')
|
||
lines = MoveLine.search([('account','=',self.id),('second_currency','=',curr.id),('revaluate','=',None),('reconciliation','=',None)])
|
||
return lines
|
||
|
||
def get_all_lines_curr(self,curr):
|
||
MoveLine = Pool().get('account.move.line')
|
||
lines = MoveLine.search([('account','=',self.id)])#,('second_currency','=',curr.id)])
|
||
return lines
|
||
|
||
def get_lines_curr_reval(self,id):
|
||
MoveLine = Pool().get('account.move.line')
|
||
lines = MoveLine.search([('account','=',self.id),('revaluate','=',id)])
|
||
return lines
|
||
|
||
def revaluate_fx(self,reval_date,after):
|
||
Currency = Pool().get('currency.currency')
|
||
MoveLine = Pool().get('account.move.line')
|
||
Configuration = Pool().get('account.configuration')
|
||
configuration = Configuration(1)
|
||
Period = Pool().get('account.period')
|
||
Move = Pool().get('account.move')
|
||
currencies = self.get_foreign_currencies()
|
||
logger.info("REVALUATE_ACCOUNT:%s",self.id)
|
||
if currencies:
|
||
for curr in currencies:
|
||
logger.info("REVALUATE_CURR:%s",curr.id)
|
||
if self.fx_eval_detail:
|
||
lines = self.get_lines_curr(curr)
|
||
logger.info("REVALUATE_DETAIL:%s",lines)
|
||
if lines:
|
||
for l in lines:
|
||
reval_lines = self.get_lines_curr_reval(l.id)
|
||
logger.info("REVALUATE_REVAL_LINES:%s",l)
|
||
with Transaction().set_context(date=reval_date):
|
||
amount_converted = Currency.compute(curr,l.amount_second_currency, self.company.currency)
|
||
amount_reval = sum([(e.debit-e.credit) for e in reval_lines])
|
||
to_add = amount_converted - ((l.debit-l.credit) + amount_reval)
|
||
if to_add != 0:
|
||
second_amount_to_add = Currency.compute(self.company.currency,to_add,curr)
|
||
line = MoveLine()
|
||
line.account = self.id
|
||
line.revaluate = l.id
|
||
line.credit = -to_add if to_add < 0 else 0
|
||
line.debit = to_add if to_add > 0 else 0
|
||
line.lot = l.lot
|
||
line.party = l.party
|
||
line.maturity_date = reval_date
|
||
logger.info("REVALUATE_ACC:%s",line)
|
||
line_ = MoveLine()
|
||
if to_add < 0:
|
||
line_.account = configuration.get_multivalue('currency_exchange_credit_account', company=self.company.id)
|
||
else:
|
||
line_.account = configuration.get_multivalue('currency_exchange_debit_account', company=self.company.id)
|
||
line_.credit = to_add if to_add > 0 else 0
|
||
line_.debit = -to_add if to_add < 0 else 0
|
||
line_.lot = l.lot
|
||
line_.maturity_date = reval_date
|
||
logger.info("REVALUATE_EX:%s",line_)
|
||
move = Move()
|
||
move.journal = configuration.get_multivalue('currency_exchange_journal', company=self.company.id)
|
||
period = Period.find(self.company, date=reval_date)
|
||
move.date = reval_date
|
||
move.period = period
|
||
#move.origin = forex
|
||
move.company = self.company
|
||
move.lines = [line,line_]
|
||
Move.save([move])
|
||
all_lines = self.get_all_lines_curr(curr)
|
||
sum_second_amount = sum([e.amount_second_currency for e in all_lines if e.amount_second_currency])
|
||
logger.info("REVALUATE_SUM_SECOND:%s",sum_second_amount)
|
||
if sum_second_amount == Decimal(0):
|
||
base_curr_sum = sum([(e.debit-e.credit) for e in all_lines])
|
||
if abs(base_curr_sum) > 0:
|
||
line = MoveLine()
|
||
line.account = self.id
|
||
line.party = 1904 #TBN
|
||
line.credit = base_curr_sum if base_curr_sum > 0 else 0
|
||
line.debit = -base_curr_sum if base_curr_sum < 0 else 0
|
||
line.maturity_date = reval_date
|
||
logger.info("REVALUATE_ACC_:%s",line)
|
||
line_ = MoveLine()
|
||
if base_curr_sum < 0:
|
||
line_.account = configuration.get_multivalue('currency_exchange_credit_account', company=self.company.id)
|
||
else:
|
||
line_.account = configuration.get_multivalue('currency_exchange_debit_account', company=self.company.id)
|
||
line_.credit = -base_curr_sum if base_curr_sum < 0 else 0
|
||
line_.debit = base_curr_sum if base_curr_sum > 0 else 0
|
||
line_.maturity_date = reval_date
|
||
logger.info("REVALUATE_EX:%s",line_)
|
||
move = Move()
|
||
move.journal = configuration.get_multivalue('currency_exchange_journal', company=self.company.id)
|
||
period = Period.find(self.company, date=reval_date)
|
||
move.date = reval_date
|
||
move.period = period
|
||
#move.origin = forex
|
||
move.company = self.company
|
||
move.lines = [line,line_]
|
||
Move.save([move])
|
||
|
||
else:
|
||
lines = self.get_all_lines_curr(curr)
|
||
with Transaction().set_context(date=reval_date):
|
||
sum_second_amount = sum([e.amount_second_currency for e in lines if e.amount_second_currency])
|
||
logger.info("REVALUATE_SECOND:%s",sum_second_amount)
|
||
amount_converted = Currency.compute(curr,sum_second_amount, self.company.currency)
|
||
logger.info("REVALUATE_SECOND2:%s",amount_converted)
|
||
total_amount = sum([(e.debit-e.credit) for e in lines])
|
||
logger.info("REVALUATE_SECOND3:%s",total_amount)
|
||
to_add = amount_converted - total_amount
|
||
if to_add != 0:
|
||
second_amount_to_add = Currency.compute(self.company.currency,to_add,curr)
|
||
line = MoveLine()
|
||
line.account = self.id
|
||
line.credit = -to_add if to_add < 0 else 0
|
||
line.debit = to_add if to_add > 0 else 0
|
||
line.party = self.company.party.id
|
||
line.maturity_date = reval_date
|
||
logger.info("REVALUATE_ACC:%s",line)
|
||
line_ = MoveLine()
|
||
if to_add < 0:
|
||
line_.account = configuration.get_multivalue('currency_exchange_credit_account', company=self.company.id)
|
||
else:
|
||
line_.account = configuration.get_multivalue('currency_exchange_debit_account', company=self.company.id)
|
||
line_.credit = to_add if to_add > 0 else 0
|
||
line_.debit = -to_add if to_add < 0 else 0
|
||
line_.maturity_date = reval_date
|
||
logger.info("REVALUATE_EX:%s",line_)
|
||
move = Move()
|
||
move.journal = configuration.get_multivalue('currency_exchange_journal', company=self.company.id)
|
||
period = Period.find(self.company, date=reval_date)
|
||
move.date = reval_date
|
||
move.period = period
|
||
#move.origin = forex
|
||
move.company = self.company
|
||
move.lines = [line,line_]
|
||
Move.save([move])
|
||
|
||
class Revaluate(Wizard):
|
||
'Revaluate'
|
||
__name__ = 'account.revaluate'
|
||
start = StateView(
|
||
'account.revaluate.start',
|
||
'purchase_trade.revaluate_start_view_form', [
|
||
Button("Cancel", 'end', 'tryton-cancel'),
|
||
Button("Revaluate", 'revaluate', 'tryton-ok', default=True),
|
||
])
|
||
revaluate = StateTransition()
|
||
|
||
def transition_revaluate(self):
|
||
Account = Pool().get('account.account')
|
||
accounts = Account.search([('fx_eval','=',True)])
|
||
if accounts:
|
||
for acc in accounts:
|
||
acc.revaluate_fx(self.start.revaluation_date,self.start.delete_after)
|
||
|
||
return 'end'
|
||
|
||
class RevaluateStart(ModelView):
|
||
"Revaluate"
|
||
__name__ = 'account.revaluate.start'
|
||
revaluation_date = fields.Date(
|
||
"Revaluation date")
|
||
delete_after = fields.Boolean(
|
||
"Delete lines after revaluation date")
|
||
|
||
@classmethod
|
||
def default_revaluation_date(cls):
|
||
Date = Pool().get('ir.date')
|
||
return Date.today()
|
||
|
||
@classmethod
|
||
def default_delete_after(cls):
|
||
return False
|
||
|
||
|
||
class ShipmentTemplateReportMixin:
|
||
|
||
@classmethod
|
||
def _get_purchase_trade_configuration(cls):
|
||
Configuration = Pool().get('purchase_trade.configuration')
|
||
configurations = Configuration.search([], limit=1)
|
||
return configurations[0] if configurations else None
|
||
|
||
@classmethod
|
||
def _get_action_report_path(cls, action):
|
||
if isinstance(action, dict):
|
||
return action.get('report') or ''
|
||
return getattr(action, 'report', '') or ''
|
||
|
||
@classmethod
|
||
def _resolve_template_path(cls, field_name, default_prefix):
|
||
config = cls._get_purchase_trade_configuration()
|
||
template = getattr(config, field_name, '') if config else ''
|
||
template = (template or '').strip()
|
||
if not template:
|
||
raise UserError('No template found')
|
||
if '/' not in template:
|
||
return f'{default_prefix}/{template}'
|
||
return template
|
||
|
||
@classmethod
|
||
def _get_resolved_action(cls, action):
|
||
report_path = cls._resolve_configured_report_path(action)
|
||
if isinstance(action, dict):
|
||
resolved = dict(action)
|
||
resolved['report'] = report_path
|
||
return resolved
|
||
setattr(action, 'report', report_path)
|
||
return action
|
||
|
||
@classmethod
|
||
def _execute(cls, records, header, data, action):
|
||
resolved_action = cls._get_resolved_action(action)
|
||
return super()._execute(records, header, data, resolved_action)
|
||
|
||
|
||
class ShipmentShippingReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
|
||
__name__ = 'stock.shipment.in.shipping'
|
||
|
||
@classmethod
|
||
def _resolve_configured_report_path(cls, action):
|
||
return cls._resolve_template_path(
|
||
'shipment_shipping_report_template', 'stock')
|
||
|
||
|
||
class ShipmentInsuranceReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
|
||
__name__ = 'stock.shipment.in.insurance'
|
||
|
||
@classmethod
|
||
def _resolve_configured_report_path(cls, action):
|
||
return cls._resolve_template_path(
|
||
'shipment_insurance_report_template', 'stock')
|
||
|
||
|
||
class ShipmentPackingListReport(ShipmentTemplateReportMixin, BaseSupplierShipping):
|
||
__name__ = 'stock.shipment.in.packing_list'
|
||
|
||
@classmethod
|
||
def _resolve_configured_report_path(cls, action):
|
||
return cls._resolve_template_path(
|
||
'shipment_packing_list_report_template', 'stock')
|