566 lines
19 KiB
Python
566 lines
19 KiB
Python
from trytond.model import ModelSQL, ModelView, Workflow, fields
|
|
from trytond.pool import Pool
|
|
from trytond.transaction import Transaction
|
|
from trytond.pyson import Bool, Eval, Id, If, PYSONEncoder
|
|
from trytond.wizard import Wizard, StateView, StateTransition, Button
|
|
|
|
from datetime import datetime
|
|
import re
|
|
|
|
class LCIncoming(ModelSQL, ModelView, Workflow):
|
|
'LC Incoming'
|
|
__name__ = 'lc.letter.incoming'
|
|
|
|
name = fields.Char('Number')
|
|
type = fields.Selection([
|
|
('documentary', 'Documentary LC'),
|
|
('standby', 'Standby LC')
|
|
], 'Type', required=True)
|
|
|
|
sale = fields.Many2One('sale.sale', 'Sale')
|
|
|
|
company = fields.Many2One('company.company', 'Company')
|
|
applicant = fields.Many2One('party.party', 'Applicant')
|
|
beneficiary = fields.Many2One('party.party', 'Beneficiary')
|
|
|
|
# Banques
|
|
issuing_bank = fields.Many2One('party.party', 'Issuing Bank')
|
|
advising_bank = fields.Many2One('party.party', 'Advising Bank')
|
|
confirming_bank = fields.Many2One('party.party', 'Confirming Bank')
|
|
reimbursing_bank = fields.Many2One('party.party', 'Reimbursing Bank')
|
|
|
|
# Montants et conditions
|
|
amount = fields.Numeric('Amount', digits=(16, 2))
|
|
currency = fields.Many2One('currency.currency', 'Currency')
|
|
tolerance_plus = fields.Numeric('Tolerance + %', digits=(6, 2))
|
|
tolerance_minus = fields.Numeric('Tolerance - %', digits=(6, 2))
|
|
|
|
# Conditions de livraison
|
|
incoterm = fields.Many2One('incoterm.incoterm', 'Incoterm')
|
|
port_of_loading = fields.Many2One('stock.location','Port of Loading')
|
|
port_of_discharge = fields.Many2One('stock.location','Port of Discharge')
|
|
final_destination = fields.Many2One('stock.location','Final Destination')
|
|
partial_shipment = fields.Selection([
|
|
(None, ''),
|
|
('allowed', 'Allowed'),
|
|
('not_allowed', 'Not Allowed')
|
|
], 'Partial Shipment')
|
|
transhipment = fields.Selection([
|
|
(None, ''),
|
|
('allowed', 'Allowed'),
|
|
('not_allowed', 'Not Allowed')
|
|
], 'Transhipment')
|
|
|
|
# Dates critiques
|
|
latest_shipment_date = fields.Date('Latest Shipment Date')
|
|
issue_date = fields.Date('Issue Date')
|
|
expiry_date = fields.Date('Expiry Date')
|
|
expiry_place = fields.Char('Expiry Place')
|
|
presentation_days = fields.Integer('Presentation Days')
|
|
|
|
# Règles et conditions
|
|
ruleset = fields.Selection([
|
|
(None, ''),
|
|
('ucp600', 'UCP 600'),
|
|
('isp98', 'ISP 98'),
|
|
('urdg758', 'URDG 758')
|
|
], 'Ruleset')
|
|
|
|
required_documents = fields.Many2Many(
|
|
'contract.document.type', 'lc_in', 'doc_type', 'Required Documents')
|
|
|
|
# Workflow principal
|
|
state = fields.Selection([
|
|
(None, ''),
|
|
('draft', 'Draft'),
|
|
('submitted', 'Submitted'),
|
|
('approved', 'Approved'),
|
|
('cancelled', 'Cancelled')
|
|
], 'State', readonly=True)
|
|
|
|
version = fields.Integer('Version', readonly=True)
|
|
|
|
# Documents et pièces jointes
|
|
attachments = fields.One2Many('ir.attachment', 'resource', 'Attachments',
|
|
domain=[('resource', '=', Eval('id'))], depends=['id'])
|
|
|
|
# Champs techniques
|
|
swift_message = fields.Text('SWIFT Message')
|
|
swift_type = fields.Char('SWIFT Type')
|
|
bank_reference = fields.Char('Bank Reference')
|
|
our_reference = fields.Char('Our Reference')
|
|
|
|
# Champs spécifiques Vente
|
|
receiving_bank = fields.Many2One('party.party', 'Receiving Bank')
|
|
confirming_bank_required = fields.Boolean('Confirmation Required')
|
|
confirmation_cost = fields.Numeric('Confirmation Cost', digits=(16, 2))
|
|
|
|
# Analyse de la LC reçue
|
|
risk_level = fields.Selection([
|
|
(None, ''),
|
|
('low', 'Low Risk'),
|
|
('medium', 'Medium Risk'),
|
|
('high', 'High Risk')
|
|
], 'Risk Level')
|
|
|
|
conditions_analysis = fields.Text('Conditions Analysis')
|
|
discrepancies_found = fields.One2Many('lc.discrepancy', 'lc', 'Discrepancies Found')
|
|
|
|
# Préparation documents
|
|
documents_prepared = fields.One2Many('lc.document.prepared', 'lc', 'Documents Prepared')
|
|
presentation_date = fields.Date('Presentation Date')
|
|
presentation_bank = fields.Many2One('party.party', 'Presentation Bank')
|
|
|
|
# Résultat présentation
|
|
acceptance_date = fields.Date('Acceptance Date')
|
|
payment_date = fields.Date('Payment Date')
|
|
refusal_reason = fields.Text('Refusal Reason')
|
|
swift_file = fields.Many2One('document.incoming',"Swift file")
|
|
swift_execute = fields.Boolean("Import")
|
|
swift_text = fields.Text("Message")
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(LCIncoming, cls).__setup__()
|
|
cls._transitions = set((
|
|
('draft', 'submitted'),
|
|
('submitted', 'approved'),
|
|
('draft', 'cancelled'),
|
|
('submitted', 'cancelled'),
|
|
('cancelled', 'draft'),
|
|
))
|
|
cls._buttons.update({
|
|
'cancel': {
|
|
'invisible': Eval('state').in_(['cancelled', 'approved']),
|
|
'depends': ['state'],
|
|
},
|
|
'draft': {
|
|
'invisible': Eval('state') != 'cancelled',
|
|
'depends': ['state'],
|
|
},
|
|
'submit': {
|
|
'invisible': Eval('state') != 'draft',
|
|
'depends': ['state'],
|
|
},
|
|
'approve': {
|
|
'invisible': Eval('state') != 'submitted',
|
|
'depends': ['state'],
|
|
},
|
|
})
|
|
|
|
def import_swift(self, text):
|
|
"""
|
|
Instance method: parse the text and return a dict of {field: value}
|
|
NE PAS écrire ici. Retourne seulement les valeurs à écrire.
|
|
"""
|
|
extracted = self._parse_swift_mt700(text)
|
|
return {
|
|
field: value
|
|
for field, value in extracted.items()
|
|
if hasattr(self, field) and value is not None and value != ''
|
|
}
|
|
|
|
@classmethod
|
|
def _process_swift_for_record(cls, lc, initial_vals=None):
|
|
"""
|
|
Traite un enregistrement unique : récupère swift_text si nécessaire,
|
|
appelle import_swift et applique les writes via super().write
|
|
(pour éviter de ré-appeler notre override write).
|
|
`initial_vals` : dict des valeurs passées lors du create/write initial
|
|
(peut être None).
|
|
"""
|
|
# Vérifier si on doit exécuter
|
|
should_execute = False
|
|
if initial_vals and 'swift_execute' in initial_vals:
|
|
should_execute = bool(initial_vals.get('swift_execute'))
|
|
else:
|
|
should_execute = bool(lc.swift_execute)
|
|
|
|
if not should_execute:
|
|
return
|
|
|
|
update_vals = {}
|
|
|
|
# Si un fichier a été fourni et que swift_text n'est pas déjà présent,
|
|
# remplir swift_text depuis le fichier
|
|
if lc.swift_file and not lc.swift_text:
|
|
try:
|
|
update_vals['swift_text'] = lc.swift_file.data.decode('utf-8', errors='ignore')
|
|
except Exception:
|
|
# Si décodage échoue, on peut ignorer ou logguer; ici on ignore
|
|
pass
|
|
|
|
# Si on a du texte (soit déjà soit venant du fichier), extraire
|
|
text_to_parse = update_vals.get('swift_text') or lc.swift_text
|
|
if text_to_parse:
|
|
extracted = lc.import_swift(text_to_parse)
|
|
update_vals.update(extracted)
|
|
|
|
# Toujours désactiver le flag et passer en submitted
|
|
update_vals['swift_execute'] = False
|
|
update_vals['state'] = 'submitted'
|
|
|
|
# Appliquer les modifications via la méthode parente pour éviter récursion
|
|
if update_vals:
|
|
super(LCIncoming, cls).write([lc], update_vals)
|
|
|
|
@classmethod
|
|
def create(cls, vals_list):
|
|
# Créer d'abord les enregistrements
|
|
lcs = super(LCIncoming, cls).create(vals_list)
|
|
|
|
# Pour chaque couple (record, vals) => traiter si swift_execute demandé
|
|
for lc, vals in zip(lcs, vals_list):
|
|
cls._process_swift_for_record(lc, initial_vals=vals)
|
|
|
|
return lcs
|
|
|
|
@classmethod
|
|
def write(cls, lcs, vals):
|
|
# Appeler le parent pour appliquer vals initiaux
|
|
super(LCIncoming, cls).write(lcs, vals)
|
|
|
|
# Puis traiter chaque record si nécessaire.
|
|
# On passe `vals` pour savoir si swift_execute a été fourni dans l'appel initial.
|
|
for lc in lcs:
|
|
cls._process_swift_for_record(lc, initial_vals=vals)
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
@staticmethod
|
|
def default_version():
|
|
return 1
|
|
|
|
def get_rec_name(self, name):
|
|
if self.name:
|
|
return f"{self.name}"
|
|
return f"LC - {self.create_date}"
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
return ['OR',
|
|
('name',) + tuple(clause[1:]),
|
|
]
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('cancelled')
|
|
def cancel(cls, lcs):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, lcs):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('submitted')
|
|
def submit(cls, lcs):
|
|
cls.write(lcs, {'state': 'submitted'})
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('approved')
|
|
def approve(cls, lcs):
|
|
for lc in lcs:
|
|
cls.write([lc], {'state': 'approved'})
|
|
|
|
@classmethod
|
|
def create_from_sale(cls, sale_id):
|
|
"""Méthode de base pour création depuis vente"""
|
|
Sale = Pool().get('sale.sale')
|
|
sale = Sale(sale_id)
|
|
return {
|
|
'sale': sale.id,
|
|
'company': sale.company.id,
|
|
'applicant': sale.party.id,
|
|
'beneficiary': sale.company.party.id,
|
|
'currency': sale.currency.id if sale.currency else None,
|
|
'port_of_loading': sale.from_location.id,
|
|
'port_of_discharge': sale.to_location.id,
|
|
'state': 'draft',
|
|
'type': 'documentary',
|
|
'risk_level': 'medium',
|
|
}
|
|
|
|
def _parse_swift_mt700(self, swift_text):
|
|
"""Parse le message SWIFT MT700"""
|
|
data = {}
|
|
|
|
# Ne pas appeler les extracteurs ici — passer des callables
|
|
patterns = {
|
|
'name': r':20:(.+)',
|
|
'issue_date': r':31C:(\d{6})',
|
|
'expiry_date': r':31D:(\d{6})',
|
|
'expiry_place': r':31D:\d{6}\s*(.+)',
|
|
# callables qui prendront swift_text en argument
|
|
'applicant': lambda txt: self._extract_from_swift(txt, ':50:'),
|
|
'beneficiary': lambda txt: self._extract_from_swift(txt, ':59:'),
|
|
'amount': r':32B:([A-Z]{3})\s*([\d,.\s]+)',
|
|
'issuing_bank': lambda txt: self._extract_from_swift(txt, ':52A:'),
|
|
'advising_bank': lambda txt: self._extract_from_swift(txt, ':53A:'),
|
|
'partial_shipment': r':43P:(.+)',
|
|
'transhipment': r':43T:(.+)',
|
|
'port_of_loading': lambda txt: self._extract_from_swift(txt, ':53A:'),
|
|
'port_of_discharge': lambda txt: self._extract_from_swift(txt, ':53A:'),
|
|
'latest_shipment_date': r':44C:(\d{6})',
|
|
'presentation_days': r':48:(\d+)',
|
|
}
|
|
|
|
for field, pattern in patterns.items():
|
|
if callable(pattern):
|
|
# appelle le callable avec le texte swift
|
|
try:
|
|
value = pattern(swift_text)
|
|
except Exception:
|
|
value = None
|
|
# n'ajouter que si on a une valeur non vide
|
|
if value:
|
|
data[field] = value.strip() if isinstance(value, str) else value
|
|
else:
|
|
match = re.search(pattern, swift_text, re.MULTILINE | re.IGNORECASE)
|
|
if match:
|
|
if field == 'amount':
|
|
# group(1) = currency, group(2) = montant
|
|
currency = match.group(1)
|
|
amount_str = match.group(2)
|
|
# normaliser le montant: enlever espaces et remplacer la virgule décimale par point
|
|
cleaned = amount_str.replace(' ', '').replace(',', '.')
|
|
try:
|
|
data['amount'] = float(cleaned)
|
|
data['currency'] = self._find_currency(currency)
|
|
except ValueError:
|
|
# si parse échoue, stocker en string pour debug
|
|
data['amount_raw'] = amount_str
|
|
elif field in ['issue_date', 'expiry_date', 'latest_shipment_date']:
|
|
date_str = match.group(1)
|
|
if len(date_str) == 6:
|
|
# SWIFT YYMMDD -> interpréter comme 20YY (ou adapter si besoin)
|
|
year = int(date_str[0:2]) + 2000
|
|
month = int(date_str[2:4])
|
|
day = int(date_str[4:6])
|
|
try:
|
|
data[field] = datetime(year, month, day).date()
|
|
except ValueError:
|
|
# date invalide : ne rien faire ou logger
|
|
pass
|
|
else:
|
|
# ici on sait qu'il y a un groupe capturant (1)
|
|
data[field] = match.group(1).strip()
|
|
|
|
return data
|
|
def _extract_from_swift(self, swift_text, field_tag):
|
|
"""Extrait les informations de partie depuis SWIFT"""
|
|
pattern = field_tag + r'\s*(.+)'
|
|
match = re.search(pattern, swift_text, re.MULTILINE | re.IGNORECASE)
|
|
if match:
|
|
party_name = match.group(1).strip().split(',')[0].strip()
|
|
return self._find_record_from_text(party_name)
|
|
return None
|
|
|
|
def _extract_name(self, text):
|
|
if not text:
|
|
return None
|
|
# On prend avant la première virgule
|
|
return text.split(',')[0].strip()
|
|
|
|
def _find_record_from_text(self, record_text):
|
|
Party = Pool().get('party.party')
|
|
Location = Pool().get('stock.location')
|
|
|
|
if not record_text:
|
|
return None
|
|
|
|
# Extraire le nom
|
|
name = self._extract_name(record_text)
|
|
|
|
if not name:
|
|
return None
|
|
|
|
# 1) Exact match
|
|
parties = Party.search([
|
|
('name', '=', name),
|
|
])
|
|
if parties:
|
|
return parties[0]
|
|
|
|
# 2) Match insensible aux accents et majuscules
|
|
parties = Party.search([
|
|
('name', 'ilike', name),
|
|
])
|
|
if parties:
|
|
return parties[0]
|
|
|
|
# 3) Match partiel (ex: "SAFTCO" dans "SAFTCO SA")
|
|
keyword = name.split()[0] # Premier mot du nom
|
|
parties = Party.search([
|
|
('name', 'ilike', f'%{keyword}%'),
|
|
])
|
|
if parties:
|
|
return parties[0]
|
|
|
|
locations = Location.search([
|
|
('name', '=', name),
|
|
])
|
|
if locations:
|
|
return locations[0]
|
|
|
|
# Aucun match trouvé
|
|
return None
|
|
|
|
def _find_currency(self, currency_code):
|
|
"""Trouve la devise par son code"""
|
|
Currency = Pool().get('currency.currency')
|
|
currencies = Currency.search([('code', '=', currency_code)], limit=1)
|
|
return currencies[0].id if currencies else None
|
|
|
|
def analyze_conditions(self):
|
|
"""Analyse automatique des conditions de la LC"""
|
|
analysis = []
|
|
risks = []
|
|
lc = self
|
|
|
|
# Vérification dates
|
|
if lc.expiry_date and lc.expiry_date < datetime.now().date():
|
|
risks.append("LC expirée")
|
|
|
|
if lc.latest_shipment_date and lc.latest_shipment_date < datetime.now().date():
|
|
risks.append("Date de shipment dépassée")
|
|
|
|
# Vérification documents
|
|
if not lc.required_documents:
|
|
risks.append("Aucun document spécifié")
|
|
|
|
# Vérification banques
|
|
if not lc.issuing_bank:
|
|
risks.append("Banque émettrice non spécifiée")
|
|
|
|
# Détermination niveau de risque
|
|
if len(risks) == 0:
|
|
lc.risk_level = 'low'
|
|
elif len(risks) <= 2:
|
|
lc.risk_level = 'medium'
|
|
else:
|
|
lc.risk_level = 'high'
|
|
|
|
lc.conditions_analysis = "\n".join(analysis + ["RISKS:"] + risks)
|
|
lc.save()
|
|
|
|
return lc.risk_level
|
|
|
|
def prepare_documents_checklist(self):
|
|
"""Prépare la checklist des documents à produire"""
|
|
lc = self
|
|
LCDocumentPrepared = Pool().get('lc.document.prepared')
|
|
|
|
# Crée les entrées pour chaque document requis
|
|
documents_to_prepare = []
|
|
if lc.required_documents:
|
|
for doc_type in lc.required_documents:
|
|
documents_to_prepare.append({
|
|
'lc': lc.id,
|
|
'document_type': doc_type.id,
|
|
'required': True,
|
|
'status': 'pending'
|
|
})
|
|
|
|
if documents_to_prepare:
|
|
LCDocumentPrepared.create(documents_to_prepare)
|
|
|
|
# lc.state = 'under_review'
|
|
lc.save()
|
|
return len(documents_to_prepare)
|
|
|
|
class ImportSwiftStart(ModelView):
|
|
'Import SWIFT Start'
|
|
__name__ = 'lc.import_swift.start'
|
|
|
|
file_ = fields.Binary('SWIFT MT700 file', required=True)
|
|
filename = fields.Char('Filename')
|
|
|
|
class ImportSwift(Wizard):
|
|
'Import SWIFT'
|
|
__name__ = 'lc.import_swift'
|
|
|
|
start = StateView(
|
|
'lc.import_swift.start',
|
|
'purchase_trade.import_swift_start_view_form',
|
|
[
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('Import', 'import_', 'tryton-ok', default=True),
|
|
]
|
|
)
|
|
|
|
import_ = StateTransition()
|
|
|
|
def transition_import_(self):
|
|
LC = Pool().get('lc.letter.incoming')
|
|
active_id = Transaction().context.get('active_id')
|
|
lc = LC(active_id)
|
|
|
|
content = self.start.file_
|
|
text = content.decode('utf-8')
|
|
|
|
extracted = lc._parse_swift_mt700(text)
|
|
|
|
update_vals = {}
|
|
for field, value in extracted.items():
|
|
if hasattr(lc, field) and value:
|
|
update_vals[field] = value
|
|
|
|
# Met à jour tous les champs via write()
|
|
if update_vals:
|
|
LC.write([lc], update_vals)
|
|
|
|
# Passe l'état via write()
|
|
LC.write([lc], {'state': 'submitted'})
|
|
|
|
return 'end'
|
|
|
|
def end(self):
|
|
# Récupérer active_id du contexte
|
|
ctx = Transaction().context
|
|
active_id = ctx.get('active_id') or (ctx.get('active_ids') and ctx.get('active_ids')[0])
|
|
if not active_id:
|
|
return 'reload'
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'lc.letter.incoming',
|
|
'res_id': active_id,
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
}
|
|
|
|
|
|
|
|
class AnalyzeConditions(Wizard):
|
|
"Analyze Conditions"
|
|
__name__ = "lc.analyze.conditions"
|
|
|
|
start = StateTransition()
|
|
|
|
def transition_start(self):
|
|
self.records[0].analyze_conditions()
|
|
return 'end'
|
|
|
|
def end(self):
|
|
return 'reload'
|
|
|
|
class PrepareDocuments(Wizard):
|
|
"Prepare Documents"
|
|
__name__ = "lc.prepare.doc"
|
|
|
|
start = StateTransition()
|
|
|
|
def transition_start(self):
|
|
self.records[0].prepare_documents_checklist()
|
|
return 'end'
|
|
|
|
def end(self):
|
|
return 'reload'
|