Initial commit: OCA Edi packages (42 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:05 +02:00
commit df976c03db
2184 changed files with 571602 additions and 0 deletions

View file

@ -0,0 +1 @@
from . import despatch_advice_import

View file

@ -0,0 +1,312 @@
# Copyright 2020 ACSONE SA/NV
# Copyright 2025 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import mimetypes
from base64 import b64decode, b64encode
from lxml import etree
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_compare
logger = logging.getLogger(__name__)
class DespatchAdviceImport(models.TransientModel):
_name = "despatch.advice.import"
_description = "Despatch Advice Import from Files"
document = fields.Binary(
string="XML or PDF Despatch Advice",
required=True,
help="Upload an Despatch Advice file that you received from "
"your supplier. Supported formats: XML and PDF "
"(PDF with an embeded XML file).",
)
filename = fields.Char(string="File Name")
allow_validate_over_qty = fields.Boolean(
"Allow Validate Over Quantity", default=True
)
# Format of parsed despatch advice
# {
# 'ref': 'PO01234' # the buyer party identifier
# # (specified into the Order document -> po's name)
# 'despatch_advice_type_code': ' scheduled | delivered'
# 'supplier': {'vat': 'FR25499247138'},
# 'company': {'vat': 'FR12123456789'}, # Only used to check we are not
# # importing the quote in the
# # wrong company by mistake
# 'estimated_delivery_date': '2020-11-20'
# 'lines': [{
# 'id': 123456,
# 'qty': 2.5,
# 'uom': {'unece_code': 'C62'},
# 'backorder_qty: None # if provided and qty != expected
# # the backorder qty will be delivered
# # in a next shipping
# }]
@api.model
def parse_despatch_advice(self, document, filename):
if not document:
raise UserError(_("Missing document file"))
if not filename:
raise UserError(_("Missing document filename"))
filetype = mimetypes.guess_type(filename)[0]
logger.debug("DespatchAdvice file mimetype: %s", filetype)
if filetype in ["application/xml", "text/xml"]:
try:
xml_root = etree.fromstring(document)
except Exception as err:
raise UserError(_("This XML file is not XML-compliant")) from err
if logger.isEnabledFor(logging.DEBUG):
pretty_xml_string = etree.tostring(
xml_root, pretty_print=True, encoding="UTF-8", xml_declaration=True
)
logger.debug("Starting to import the following XML file:")
logger.debug(pretty_xml_string)
parsed_despatch_advice = self.parse_xml_despatch_advice(xml_root)
elif filetype == "application/pdf":
parsed_despatch_advice = self.parse_pdf_despatch_advice(document)
else:
raise UserError(
_(
"This file '%s' is not recognised as XML nor PDF file. "
"Please check the file and it's extension."
)
% filename
)
logger.debug("Result of Despatch Advice parsing: ", parsed_despatch_advice)
if "attachments" not in parsed_despatch_advice:
parsed_despatch_advice["attachments"] = {}
parsed_despatch_advice["attachments"][filename] = b64encode(document)
if "chatter_msg" not in parsed_despatch_advice:
parsed_despatch_advice["chatter_msg"] = []
if parsed_despatch_advice.get("company") and not self.env.context.get(
"edi_skip_company_check"
):
self.env["business.document.import"]._check_company(
parsed_despatch_advice["company"], parsed_despatch_advice["chatter_msg"]
)
defaults = self.env.context.get("despatch_advice_import__default_vals", {}).get(
"despatch_advice", {}
)
parsed_despatch_advice.update(defaults)
return parsed_despatch_advice
@api.model
def parse_xml_despatch_advice(self, xml_root):
raise UserError(
_(
"This type of XML Order Response is not supported. Did you "
"install the module to support this XML format?"
)
)
@api.model
def parse_pdf_despatch_advice(self, document):
"""
Get PDF attachments, filter on XML files and call import_order_xml
"""
xml_files_dict = self.get_xml_files_from_pdf(document)
if not xml_files_dict:
raise UserError(_("There are no embedded XML file in this PDF file."))
for xml_filename, xml_root in xml_files_dict.items():
logger.info("Trying to parse XML file %s", xml_filename)
try:
parsed_despatch_advice = self.parse_xml_despatch_advice(xml_root)
return parsed_despatch_advice
except Exception:
continue
raise UserError(
_(
"This type of XML Order Document is not supported. Did you "
"install the module to support this XML format?"
)
)
def process_document(self):
self.ensure_one()
parsed_order_document = self.parse_despatch_advice(
b64decode(self.document), self.filename
)
self.process_data(parsed_order_document)
def _collect_lines_by_id(self, lines_doc, key="order_line_id"):
lines_by_id = {}
for line in lines_doc:
line_id = int(line[key])
if line_id in lines_by_id:
lines_by_id[line_id]["qty"] += line["qty"]
lines_by_id[line_id]["backorder_qty"] += line["backorder_qty"]
if "product_lot" in line:
lines_by_id[line_id]["product_lot"].append(line["product_lot"])
lines_by_id[line_id]["product_lot"] = list(
set(lines_by_id[line_id]["product_lot"])
)
lines_by_id[line_id]["uom"]["unece_code"].append(
line["uom"]["unece_code"]
)
lines_by_id[line_id]["uom"]["unece_code"] = list(
set(lines_by_id[line_id]["uom"]["unece_code"])
)
else:
lines_by_id[line_id] = line
if "product_lot" in line:
lines_by_id[line_id]["product_lot"] = [
lines_by_id[line_id]["product_lot"]
]
lines_by_id[line_id]["uom"]["unece_code"] = [
lines_by_id[line_id]["uom"]["unece_code"]
]
return lines_by_id
def process_data(self, parsed_order_document):
po_name = parsed_order_document.get("ref")
lines_doc = parsed_order_document.get("lines")
lines_by_id = self._collect_lines_by_id(lines_doc)
lines = self.env["purchase.order.line"].browse(lines_by_id.keys())
for line in lines:
order = line.order_id
line_info = lines_by_id.get(line.id)
if line_info["ref"]:
if order.name != line_info["ref"]:
raise UserError(
_("No purchase order found for name %s.") % line_info["ref"],
)
else:
if order.name != po_name:
raise UserError(_("No purchase order found for name %s.") % po_name)
stock_moves = line.move_ids.filtered(
lambda x: x.state not in ("cancel", "done")
)
moves_qty = sum(stock_moves.mapped("product_qty"))
if line_info["qty"] == moves_qty:
self._process_accepted(stock_moves, parsed_order_document)
elif line_info["qty"] > moves_qty and self.allow_validate_over_qty:
self._process_accepted(
stock_moves, parsed_order_document, forced_qty=line_info["qty"]
)
elif not line_info["qty"] and not line_info["backorder_qty"]:
self._process_rejected(stock_moves, parsed_order_document)
else:
self._process_conditional(stock_moves, parsed_order_document, line_info)
self._process_picking_done(lines[0].move_ids[0])
def _process_picking_done(self, move):
picking = move.picking_id
if all(line.state == "cancel" for line in picking.move_ids):
return True
# skip backorder wizard
picking.with_context(
skip_immediate=True, skip_backorder=True, skip_sms=True, skip_expired=True
).button_validate()
def _cancel_extra_moves(self, moves):
# Loose dependency with stock_picking_restrict_cancel_printed module
# that checks we are canceling the backorder to allow move cancellation.
# Mimic odoo setting this cancel_backorder context variable in this case.
moves.with_context(cancel_backorder=True)._action_cancel()
def _process_rejected(self, stock_moves, parsed_order_document):
parsed_order_document["chatter_msg"] = parsed_order_document.get(
"chatter_msg", []
)
parsed_order_document["chatter_msg"].append(
_("Delivery cancelled by the supplier.")
)
self._cancel_extra_moves(stock_moves)
def _process_accepted(self, stock_moves, parsed_order_document, forced_qty=False):
parsed_order_document["chatter_msg"] = (
parsed_order_document["chatter_msg"] or []
)
parsed_order_document["chatter_msg"].append(
_("Delivery confirmed by the supplier.")
)
stock_moves._action_confirm()
stock_moves._action_assign()
for move in stock_moves:
move.quantity_done = forced_qty or move.product_qty
def _process_conditional(self, moves, parsed_order_document, line):
precision = self.env["decimal.precision"].precision_get(
"Product Unit of Measure"
)
chatter = parsed_order_document["chatter_msg"] = (
parsed_order_document["chatter_msg"] or []
)
chatter.append(_("Delivery confirmed with amendment by the supplier."))
qty = line["qty"]
backorder_qty = line["backorder_qty"]
moves_qty = sum(moves.mapped("product_qty"))
if float_compare(qty, moves_qty, precision_digits=precision) >= 0:
raise UserError(
_("The product quantity is greater than the original product quantity")
)
# confirmed qty < ordered qty
move_ids_to_backorder = []
move_ids_to_cancel = []
for move in moves:
if (
float_compare(qty, move.product_uom_qty, precision_digits=precision)
>= 0
):
# qty planned => qty into the stock move: Keep it
qty -= move.product_uom_qty
continue
if (
qty
and float_compare(qty, move.product_uom_qty, precision_digits=precision)
< 0
):
# qty planned < qty into the stock move: Split it
new_vals = move._split(move.product_uom_qty - qty)
move.quantity_done = move.product_qty
move = self.env["stock.move"].create(new_vals[0])
qty -= move.product_uom_qty
if not backorder_qty:
# if no backorder -> we must cancel the move
move_ids_to_cancel.append(move.id)
continue
# from here we process the backorder qty
# we distribute this qty into the remaining moves and
# if this qty is < than the expected one, we split and cancel the
# remaining qty
if (
float_compare(
backorder_qty, move.product_uom_qty, precision_digits=precision
)
< 0
):
# backorder_qty < qty into the move -> split the move
# and cancel remaining qty
move._action_confirm(merge=False)
new_vals = move._split(move.product_uom_qty - backorder_qty)
move_ids_to_cancel.append(self.env["stock.move"].create(new_vals[0]).id)
backorder_qty -= move.product_uom_qty
move_ids_to_backorder.append(move.id)
# cancel moves to cancel
if move_ids_to_cancel:
moves_to_cancel = self.env["stock.move"].browse(move_ids_to_cancel)
self._cancel_extra_moves(moves_to_cancel)
# move backorder moves to a backorder
if move_ids_to_backorder:
moves_to_backorder = self.env["stock.move"].browse(move_ids_to_backorder)
for move in moves_to_backorder:
move._action_confirm(merge=False)

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2020 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="despatch_advice_import_form_view" model="ir.ui.view">
<field name="name">despatch.advice.import (in purchase_order_import)</field>
<field name="model">despatch.advice.import</field>
<field name="arch" type="xml">
<form string="Despatch Advice Import">
<group colspan="4" name="help-import">
<div colspan="2">
<p
>Upload below the DespatchAdvice you received from your supplier. When you click on the import button:</p>
<ol>
<li
>If it is an XML file, Odoo will parse it if the module that adds support for this XML format is installed. For the <a
href="http://ubl.xml.org/"
target="_blank"
>Universal Business Language</a> format (UBL), you should install the module <em
>despatch_advice_import_ubl</em>.</li>
<li
>If it is a PDF file, Odoo will try to find an XML file in the attachments of the PDF file and then use this XML file.</li>
</ol>
</div>
</group>
<group name="main">
<field name="document" filename="filename" />
<field name="filename" invisible="1" />
<field name="allow_validate_over_qty" />
</group>
<footer>
<button
name="process_document"
type="object"
class="oe_highlight"
string="Import document"
/>
<button special="cancel" string="Cancel" class="oe_link" />
</footer>
</form>
</field>
</record>
<record id="despatch_advice_import_action" model="ir.actions.act_window">
<field name="name">Import</field>
<field name="res_model">despatch.advice.import</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<record model="ir.ui.menu" id="despatch_advice_import_importer_menu">
<field name="name">UBL Despatch Advice Importer</field>
<field name="parent_id" ref="purchase.menu_procurement_management" />
<field name="action" ref="despatch_advice_import_action" />
<field name="sequence" eval="99" />
</record>
</odoo>