diff --git a/export_invoice_edi_auchan/README.rst b/export_invoice_edi_auchan/README.rst new file mode 100644 index 000000000..e69de29bb diff --git a/export_invoice_edi_auchan/__init__.py b/export_invoice_edi_auchan/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/export_invoice_edi_auchan/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/export_invoice_edi_auchan/__manifest__.py b/export_invoice_edi_auchan/__manifest__.py new file mode 100644 index 000000000..8f173c1a5 --- /dev/null +++ b/export_invoice_edi_auchan/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Custom export invoice edi format Auchan", + "version": "16.0.0.0.0", + "author": "Akretion", + "category": "EDI", + "website": "https://github.com/akretion/ak-odoo-incubator", + "license": "AGPL-3", + "depends": [ + "account", + "stock_picking_invoice_link", + "fs_storage", + "attachment_synchronize_record", + ], + "data": [ + "data/task_data.xml", + "views/res_partner.xml", + "views/account_move.xml", + ], +} diff --git a/export_invoice_edi_auchan/data/task_data.xml b/export_invoice_edi_auchan/data/task_data.xml new file mode 100644 index 000000000..09fe0ae32 --- /dev/null +++ b/export_invoice_edi_auchan/data/task_data.xml @@ -0,0 +1,16 @@ + + + + @gp Auchan + ftp + ftpgp + + + + Export EDI Auchan + + export + factures + + + diff --git a/export_invoice_edi_auchan/models/__init__.py b/export_invoice_edi_auchan/models/__init__.py new file mode 100644 index 000000000..74548553b --- /dev/null +++ b/export_invoice_edi_auchan/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_move +from . import res_partner diff --git a/export_invoice_edi_auchan/models/account_move.py b/export_invoice_edi_auchan/models/account_move.py new file mode 100644 index 000000000..c83d2b88c --- /dev/null +++ b/export_invoice_edi_auchan/models/account_move.py @@ -0,0 +1,130 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from io import StringIO +from os import linesep + +from odoo import fields, models + +from ..schema.base import SegmentInterfaceExc +from ..schema.invoice_footer import PIESegment +from ..schema.invoice_header import ENTSegment +from ..schema.invoice_line import LIGSegment +from ..schema.invoice_taxes import TVASegment +from ..schema.partner import PARSegment + +_logger = logging.getLogger() + + +class AccountMove(models.Model): + _inherit = ["account.move", "synchronize.exportable.mixin"] + _name = "account.move" + + is_edi_exportable = fields.Boolean( + related="partner_id.is_edi_exportable", + ) + regenerate_edi = fields.Boolean() + + def _find_bl_info(self): + """Find entête "Numéro de BL" and date""" + raise NotImplementedError + + def _render_segment(self, segment, vals): + try: + res = segment(**vals).render() + except SegmentInterfaceExc as e: + self.env.context["export_auchan_errors"].append(str(e)) + else: + return res + + def _prepare_export_data(self, idx): + self.ensure_one() + _logger.info(f"Exporting {self.name}") + res = [] + source_orders = self.line_ids.sale_line_ids.order_id or self.env[ + "sale.order" + ].search([("name", "=", self.invoice_origin)]) + bl_nbr, bl_date = self._find_bl_info() + self = self.with_context(export_auchan_errors=[]) + # Segment Entete facture + res.append( + self._render_segment( + ENTSegment, + { + "invoice": self, + "source_orders": source_orders, + "bl_nbr": bl_nbr, + "bl_date": bl_date, + }, + ) + ) + # segment partner + res.append( + self._render_segment( + PARSegment, + { + "invoice": self, + }, + ) + ) + # segment ligne de fatcure + for idx, line in enumerate(self.invoice_line_ids, start=1): + res.append( + self._render_segment( + LIGSegment, + { + "line": line, + "line_num": str(idx), + }, + ) + ) + # Segment pied facture + res.append( + self._render_segment( + PIESegment, + { + "invoice": self, + }, + ) + ) + # segment ligne de TVA (détail des TVA) + for tax_line in self.line_ids.filtered(lambda x: x.tax_line_id): + res.append( + self._render_segment( + TVASegment, + { + "tax_line": tax_line, + }, + ) + ) + # Segment END + res.append("END") + errs = self.env.context.get("export_auchan_errors") + if errs: + errstr = "Erreur lors de la génération du fichier Auchan: \n" + errstr += "\n".join(errs) + _logger.error(errstr) + raise ValueError(errstr) + return res + + def _get_export_task(self): + return self.env.ref("export_invoice_edi_auchan.export_to_auchan_ftp") + + def _prepare_aq_data(self, data): + _logger.info(f"Exporting {self} to EDI Auchan") + if self._name == "account.move": + return self._format_to_exportfile_auchan_edi(data) + return self._prepare_aq_data_csv(data) + + def _format_to_exportfile_auchan_edi(self, data): + txt_file = StringIO() + for row in data: + txt_file.write(row) + txt_file.write(linesep) + txt_file.seek(0) + + return txt_file.getvalue().encode("utf-8") + + def _get_export_name(self): + return self.name.replace("/", "-") diff --git a/export_invoice_edi_auchan/models/res_partner.py b/export_invoice_edi_auchan/models/res_partner.py new file mode 100644 index 000000000..1d59f809e --- /dev/null +++ b/export_invoice_edi_auchan/models/res_partner.py @@ -0,0 +1,13 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + barcode = fields.Char( + string="Code EAN", + ) + is_edi_exportable = fields.Boolean() diff --git a/export_invoice_edi_auchan/schema/base.py b/export_invoice_edi_auchan/schema/base.py new file mode 100644 index 000000000..c741747e0 --- /dev/null +++ b/export_invoice_edi_auchan/schema/base.py @@ -0,0 +1,78 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import datetime + +import freezegun +from unidecode import unidecode + +from odoo.tools import float_compare + + +class SegmentInterfaceExc(Exception): + pass + + +class SegmentInterface: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + def _format_values(self, size, value="", ctx={}): + required = True + if not ctx: + ctx = {} # Fix bug bool value + if "required" in ctx.keys(): + required = ctx.get("required") + if required and (value is False or value is None or value == ""): + raise ValueError() + if not ctx: + ctx = {} + if not value: + value = "" + if isinstance(value, freezegun.api.FakeDate): + fmt_val = datetime.date.strftime(value, "%d/%m/%Y") + elif isinstance(value, datetime.datetime): + fmt_val = datetime.date.strftime(value.date(), "%d/%m/%Y %H:%M") + elif isinstance(value, int): + fmt_val = str(value) + elif isinstance(value, float): + if float_compare(value, 0, 3) == 0 and ctx.get("empty_if_zero"): + fmt_val = "" + else: + if ctx.get("decimal_3"): + fmt_val = str("{:.3f}".format(value)) + else: + fmt_val = str("{:.2f}".format(value)) + elif isinstance(value, str): + fmt_val = unidecode(value) + else: + raise ValueError(f"Unsupported value type: {type(value)}") + if len(fmt_val) > size: + if ctx.get("truncate_silent"): + fmt_val = fmt_val[:size] + else: + errorstr = "{} trop long, taille maximale est de {}".format( + fmt_val, size + ) + if ctx: + errorstr = "contexte: {}".format(ctx) + errorstr + raise ValueError(errorstr) + return fmt_val + + def render(self): + res = "" + errors = [] + for idx, fmt_data in enumerate(self.get_values(), start=1): + try: + fmt_val = self._format_values(*fmt_data) + res += fmt_val + ";" + except ValueError: + errors += [(self.__class__.__name__, idx)] + if errors: + errstr = "" + for el in errors: + errstr += f"Segment {el[0]}: missing value on line {el[1]}\n" + raise SegmentInterfaceExc(errstr) + return res[:-1] + + def get_values(self): + raise NotImplementedError diff --git a/export_invoice_edi_auchan/schema/invoice_footer.py b/export_invoice_edi_auchan/schema/invoice_footer.py new file mode 100644 index 000000000..4723de544 --- /dev/null +++ b/export_invoice_edi_auchan/schema/invoice_footer.py @@ -0,0 +1,15 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from .base import SegmentInterface + + +class PIESegment(SegmentInterface): + def get_values(self): + return [ + (3, "PIE"), + (10, self.invoice.amount_untaxed), # Montant total hors taxes + (10, self.invoice.amount_tax), # Montant taxes + (10, self.invoice.amount_total), # Montant total TTC + ] diff --git a/export_invoice_edi_auchan/schema/invoice_header.py b/export_invoice_edi_auchan/schema/invoice_header.py new file mode 100644 index 000000000..be23b5d0c --- /dev/null +++ b/export_invoice_edi_auchan/schema/invoice_header.py @@ -0,0 +1,83 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from .base import SegmentInterface + + +class ENTSegment(SegmentInterface): + def get_values(self): + return [ + (3, "ENT"), # Étiquette de segment "ENT" + (70, self.invoice.invoice_origin), # Numéro de commande du client + ( + 10, + self.source_orders and self.source_orders[0].date_order or "", + ), # Date de commande JJ/MM/AAAA + (5, "", {"required": False}), # Heure de commande HH:MN opt + (10, "", {"required": False}), # date du message opt + (5, "", {"required": False}), # Heure du message opt + ( + 10, + self.bl_date, + ), # Date du BL JJ/MM/AAAA + ( + 35, + self.bl_nbr, + ), # num du BL JJ/MM/AAAA + (10, "", {"required": False}), # Date avis d'expédition JJ/MM/AAAA opt + (35, "", {"required": False}), # Numéro de l'avis d'expédition opt + (10, "", {"required": False}), # Date d'enlèvement JJ/MM/AAAA opt + (5, "", {"required": False}), # Heure d'enlèvement HH:MN opt + (35, self.invoice.name), # Numéro de document + ( + 16, + self.invoice.invoice_date, + ), # Date/heure facture ou avoir (document) JJ/MM/AAAA HH:MN + (10, self.invoice.invoice_date_due), # Date d'échéance JJ/MM/AAAA + ( + 7, + self.invoice.move_type == "out_invoice" + and "Facture" + or (self.invoice.move_type == "out_refund" and "Avoir") + or "", + ), # Type de document (Facture/Avoir) + # depend on 'move_type', 'in', ('out_invoice', 'out_refund') + (3, self.invoice.currency_id.name), # Code monnaie (EUR pour Euro) + ( + 10, + "", + {"required": False}, + ), # Date d'échéance pour l'escompte JJ/MM/AAAA opt + ( + 10, + "", + {"required": False}, + ), # Montant de l'escompte (le pourcentage de l'escompte est préconisé) opt + ( + 35, + "", + {"required": False}, + ), # Numéro de facture en référence (obligatoire si avoir) opt + ( + 10, + "", + {"required": False}, + ), # Date de facture en référence (obligatoire si avoir) opt + (6, "", {"required": False}), # Pourcentage de l'escompte opt + (3, "", {"required": False}), # Nb de jour de l'escompte opt + (6, "", {"required": False}), # Pourcentage de pénalité opt + (3, "", {"required": False}), # Nb de jour de pénalité opt + ( + 1, + self.invoice.env.context.get("test_mode") and "1" or "0", + ), # Document de test (1/0) + ( + 3, + "42", + ), # Code paiement (cf table ENT.27) 42 ==> Paiement à un compte + # bancaire (virement client) + # fix ==> rendre ce champs dynamique ? + (3, "MAR"), # Nature document (MAR pour marchandise et SRV pour service) + # fix ==> rendre ce champs dynamique ? + ] diff --git a/export_invoice_edi_auchan/schema/invoice_line.py b/export_invoice_edi_auchan/schema/invoice_line.py new file mode 100644 index 000000000..c9929d973 --- /dev/null +++ b/export_invoice_edi_auchan/schema/invoice_line.py @@ -0,0 +1,63 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from .base import SegmentInterface + + +class LIGSegment(SegmentInterface): + def get_values(self): + uom = ( + self.line.product_uom_id.name == "kg" + and "KGM" + or self.line.product_uom_id.name == "m" + and "MTR" + or "PCE" + ) + + return [ + (3, "LIG"), + ( + 6, + self.line_num, + ), # Numéro de ligne (de 1 à n remis à 0 pour chaque facture) + (14, self.line.product_id.barcode), # Code EAN produit + ( + 35, + self.line.product_id.default_code, + ), # Code interne produit chez le fournisseur + ( + 10, + self.line.sale_line_ids + and self.line.sale_line_ids[0].product_packaging_id.qty + or 1, + ), # Par combien + # (multiple de commande) + ( + 10, + self.line.sale_line_ids + and self.line.sale_line_ids[0].product_uom_qty + or self.line.quantity, + ), + # Quantité commandée + (3, uom), # Unité de quantité (PCE = pièce, KGM = kilogramme, MTR = mètre) + (10, self.line.quantity), # Quantité facturée + (15, self.line.price_unit), # Prix unitaire net + (3, self.line.move_id.currency_id.name), # Code monnaie (EUR = euro) + (1, "", {"required": False}), + (1, "", {"required": False}), + (5, self.line.tax_ids and self.line.tax_ids[0].amount or 0), + ( + 15, + self.line.quantity + and (self.line.price_total / self.line.quantity) + or 0.0, + ), # Prix unitaire brut + (1, "", {"required": False}), + ( + 70, + self.line.name and self.line.name.replace("\n", " ") or "", + {"truncate_silent": True}, + ), + (17, self.line.price_subtotal), # Montant Net Ht de la ligne + ] diff --git a/export_invoice_edi_auchan/schema/invoice_taxes.py b/export_invoice_edi_auchan/schema/invoice_taxes.py new file mode 100644 index 000000000..8dcc6dc93 --- /dev/null +++ b/export_invoice_edi_auchan/schema/invoice_taxes.py @@ -0,0 +1,20 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from .base import SegmentInterface + + +class TVASegment(SegmentInterface): + def get_values(self): + return [ + (3, "TVA"), # Étiquette de segment "TVA" + (5, self.tax_line.tax_line_id.amount or 0.0), + (10, self.tax_line.tax_base_amount or 0.0), + ( + 10, + self.tax_line.amount_currency + and abs(self.tax_line.amount_currency) + or 0.0, + ), + ] diff --git a/export_invoice_edi_auchan/schema/partner.py b/export_invoice_edi_auchan/schema/partner.py new file mode 100644 index 000000000..24a811654 --- /dev/null +++ b/export_invoice_edi_auchan/schema/partner.py @@ -0,0 +1,51 @@ +# Copyright 2023 Akretion +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from .base import SegmentInterface + + +class PARSegment(SegmentInterface): + def get_values(self): + return [ + (3, "PAR"), + (13, self.invoice.partner_shipping_id.barcode), # Code EAN client + ( + 35, + self.invoice.partner_shipping_id.name, + {"required": False}, + ), # Libellé client + ( + 13, + self.invoice.company_id.partner_id.barcode, + ), # Code EAN Fournisseur (vendeur) + ( + 35, + self.invoice.company_id.partner_id.name, + False, + ), # Libellé Fournisseur (vendeur) + (13, self.invoice.partner_shipping_id.barcode), # Code EAN client livré + ( + 35, + self.invoice.partner_shipping_id.name, + {"required": False}, + ), # Libellé client livré + (13, self.invoice.partner_id.barcode), # Code EAN client facturé à + ( + 35, + self.invoice.partner_id.name, + {"required": False}, + ), # Libellé client facturé à + (10, "", {"required": False}), # Code EAN factor (obligatoire si factor) + ( + 10, + "", + {"required": False}, + ), # Libellé alias factor (obligatoire si factor) + (13, self.invoice.company_id.partner_id.barcode), # Code EAN régler à + ( + 35, + self.invoice.company_id.partner_id.name, + {"required": False}, + ), # Libellé régler à + ] diff --git a/export_invoice_edi_auchan/views/account_move.xml b/export_invoice_edi_auchan/views/account_move.xml new file mode 100644 index 000000000..1854ecf58 --- /dev/null +++ b/export_invoice_edi_auchan/views/account_move.xml @@ -0,0 +1,33 @@ + + + + + account.move + + +
+ +
+ + + + + + + +
+
diff --git a/export_invoice_edi_auchan/views/res_partner.xml b/export_invoice_edi_auchan/views/res_partner.xml new file mode 100644 index 000000000..644a9361b --- /dev/null +++ b/export_invoice_edi_auchan/views/res_partner.xml @@ -0,0 +1,18 @@ + + + + + + res.partner.form (Auchan Fac edi Format) + res.partner + + + + + + + + + + diff --git a/setup/export_invoice_edi_auchan/odoo/addons/export_invoice_edi_auchan b/setup/export_invoice_edi_auchan/odoo/addons/export_invoice_edi_auchan new file mode 120000 index 000000000..108fcce6c --- /dev/null +++ b/setup/export_invoice_edi_auchan/odoo/addons/export_invoice_edi_auchan @@ -0,0 +1 @@ +../../../../export_invoice_edi_auchan \ No newline at end of file diff --git a/setup/export_invoice_edi_auchan/setup.py b/setup/export_invoice_edi_auchan/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/export_invoice_edi_auchan/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)