mirror of
https://github.com/bringout/oca-edi.git
synced 2026-04-21 16:32:06 +02:00
Initial commit: OCA Edi packages (42 packages)
This commit is contained in:
commit
df976c03db
2184 changed files with 571602 additions and 0 deletions
|
|
@ -0,0 +1 @@
|
|||
from . import despatch_advice_import
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue