diff --git a/edi_local/README.rst b/edi_local/README.rst new file mode 100644 index 0000000000..901f25d3d9 --- /dev/null +++ b/edi_local/README.rst @@ -0,0 +1,110 @@ +========= +Edi local +========= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0e8cc9318b07f55cff944722ab96576f95706b578f7fd6ccee5c21cd6d75dab0 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fedi-lightgray.png?logo=github + :target: https://github.com/OCA/edi/tree/17.0/edi_local + :alt: OCA/edi +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-17-0/edi-17-0-edi_local + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/edi&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +The module allows, through configuration, the following: + +1. Generating .txt files. +2. Reading .txt files. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Create headers for the configurations +------------------------------------- + +These headers allow us to have a configuration order independent of the +order of the configuration lines. + +Example: Header X - Sequence 1 Header Y - Sequence 2 Lines +(Configuration): + +- Header Y / Line 1 / Sequence 3 +- Header X / Line 2 / Sequence 5 +- Header Y / Line 3 / Sequence 1 + +Therefore, by grouping the lines by header, line 2 will be obtained +first, then line 3, and then line 1, in that order. + +1. Go to Settings > Technical > EDI > Edi local header +2. Create a new header +3. Save + +Known issues / Roadmap +====================== + +Add a new xml type and make the necessary modifications to generate and +read files of that type. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Binhex + +Contributors +------------ + +- [Binhex] (https://www.binhex.cloud): + + - Edilio Escalona Almira e.escalona@binhex.cloud + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/edi `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_local/__init__.py b/edi_local/__init__.py new file mode 100644 index 0000000000..0077c8866c --- /dev/null +++ b/edi_local/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/edi_local/__manifest__.py b/edi_local/__manifest__.py new file mode 100644 index 0000000000..c8c5ddd0ba --- /dev/null +++ b/edi_local/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Edi local", + "summary": "Edi local", + "version": "17.0.1.0.0", + "author": "Binhex,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/edi", + "license": "AGPL-3", + "depends": ["base", "mail"], + "data": [ + "security/ir.model.access.csv", + "data/edi_local_header_data.xml", + "data/ir_cron_data.xml", + "views/edi_local_line_views.xml", + "views/edi_local_views.xml", + "views/edi_local_header_views.xml", + "views/edi_local_menus_views.xml", + ], + "installable": True, +} diff --git a/edi_local/data/edi_local_header_data.xml b/edi_local/data/edi_local_header_data.xml new file mode 100644 index 0000000000..7dac11bbb7 --- /dev/null +++ b/edi_local/data/edi_local_header_data.xml @@ -0,0 +1,8 @@ + + + + 0 + gen + General + + diff --git a/edi_local/data/ir_cron_data.xml b/edi_local/data/ir_cron_data.xml new file mode 100644 index 0000000000..e68c8d6c49 --- /dev/null +++ b/edi_local/data/ir_cron_data.xml @@ -0,0 +1,16 @@ + + + + Edi Local: Generate files + + 1 + days + -1 + + + model.with_context(generate_with_cron=True).generate_file_cron() + code + + diff --git a/edi_local/i18n/edi_local.pot b/edi_local/i18n/edi_local.pot new file mode 100644 index 0000000000..3c0aaa7a3b --- /dev/null +++ b/edi_local/i18n/edi_local.pot @@ -0,0 +1,710 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_local +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-11-13 18:43+0000\n" +"PO-Revision-Date: 2025-11-13 18:43+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "" +"\n" +"Type: %(type)s \n" +"Value: %(value)s \n" +"Field: %(field_name)s \n" +"Expression: %(expression)s \n" +"Record [%(model_name)s]: %(record_name)s \n" +"Error: %(message)s" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__description +msgid "A description of the value the field accepts." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_ids +msgid "Activities" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_state +msgid "Activity State" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__after_header +msgid "After Header" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__type_data__alphanumeric +msgid "Alphanumeric" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__domain +msgid "Apply on" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__attachment_ids +msgid "Attachment" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__available_variables +msgid "Available Variables" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__orientation_fill_value__center +msgid "Center" +msgstr "" + +#. module: edi_local +#: model_terms:ir.ui.view,arch_db:edi_local.edi_local_view_form +msgid "" +"Check all lines without generating the final file or" +" taking any action after the evaluations." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__code +msgid "Code" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__create_uid +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__create_uid +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__create_uid +msgid "Created by" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__create_date +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__create_date +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__create_date +msgid "Created on" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__type_data__date +msgid "Date" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__decimal +msgid "Decimal" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "Define a filename" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "Define lines before generating a file" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__after_header +#: model:ir.model.fields,help:edi_local.field_edi_local_line__type_header +msgid "" +"Defines after which header the lines are generated, if not specified it is " +"generated at the end of everything" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__type_data +msgid "" +"Defines the data type of the value\n" +"Alphanumeric: Only letters and numbers are allowed. Example: Alpha12pha345\n" +"Numeric: Only numbers are allowed. Example: 12345\n" +"Date: Only dates are allowed. Example: 20251112\n" +"General: Any value is allowed. Example: Alpha1_2pha345-*_" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__size +msgid "Defines the field size. Example: 5 positions" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__dir_file +msgid "Defines the local directory where the generated files will be stored." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__decimal +msgid "" +"Defines the number of decimal places in the number. These decimal places are" +" taken into account in the length (start + size)" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__orientation_fill_value +msgid "Defines the orientation in which the filling will be carried out." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__start +msgid "" +"Defines the position from which counting will begin based on the size value." +" By default, this is the last position of the previous line in the same " +"header." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__value_fill +msgid "Defines the value to be filled, by default it is a space." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__override_file +msgid "Defines whether existing files are deleted when regenerating." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local_line__fill_value +msgid "" +"Defines whether the value size is filled with a specific character; if the " +"default character is not defined, it is filled with a space." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__description +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__description +msgid "Description" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__dir_file +msgid "Dir File" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__display_name +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__display_name +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__display_name +msgid "Display Name" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "" +"Documents of type %(document_type)s were not imported because the function " +"with structure %(func_structure)s does not exist." +msgstr "" + +#. module: edi_local +#: model:ir.ui.menu,name:edi_local.settings_edi_local +msgid "EDI" +msgstr "" + +#. module: edi_local +#: model:ir.model,name:edi_local.model_edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__edi_local_id +msgid "Edi Local" +msgstr "" + +#. module: edi_local +#: model:ir.model,name:edi_local.model_edi_local_type_header +msgid "Edi Local Type Header" +msgstr "" + +#. module: edi_local +#: model:ir.actions.server,name:edi_local.ir_cron_edi_local_generate_file_ir_actions_server +msgid "Edi Local: Generate files" +msgstr "" + +#. module: edi_local +#: model:ir.actions.act_window,name:edi_local.edi_local_tree_action +#: model:ir.ui.menu,name:edi_local.edi_local_tree_action_menu +msgid "Edi local" +msgstr "" + +#. module: edi_local +#: model:ir.model,name:edi_local.model_edi_local_line +msgid "Edi local line" +msgstr "" + +#. module: edi_local +#: model:ir.actions.act_window,name:edi_local.edi_local_type_header_tree_action +#: model:ir.ui.menu,name:edi_local.edi_local_type_header_tree_action_menu +msgid "Edi local type header" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__enabled +msgid "Enabled" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__end +msgid "End" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__field_id +msgid "Field" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__file_type +msgid "File Type" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__sequence_id +msgid "Filename" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__files_generated +msgid "Files Generated" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__fill_value +msgid "Fill Value" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__type_data__general +msgid "General" +msgstr "" + +#. module: edi_local +#: model_terms:ir.ui.view,arch_db:edi_local.edi_local_view_form +msgid "Generate file" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "Generated files:%(files_generated)s" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__has_message +msgid "Has Message" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__type__header +msgid "Header" +msgstr "" + +#. module: edi_local +#: model_terms:ir.ui.view,arch_db:edi_local.edi_local_view_form +msgid "Headers" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "" +"I do not generate documents of type %(document_type)s because the function " +"with structure %(func_structure)s does not exist." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__id +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__id +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__id +msgid "ID" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local__type__in +msgid "In" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__is_required +msgid "Is Required" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "" +"It is not a valid number with %(partial_integer)s whole digits and " +"%(partial_decimal)s decimal places." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__write_uid +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__write_uid +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__write_date +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__write_date +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__write_date +msgid "Last Updated on" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__orientation_fill_value__left +msgid "Left" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__line_ids +msgid "Line" +msgstr "" + +#. module: edi_local +#: model_terms:ir.ui.view,arch_db:edi_local.edi_local_view_form +msgid "Lines" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__local_line_ids +msgid "Local Line" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_ids +msgid "Messages" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__model_id +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__model_id +msgid "Model" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__model_name +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__model_name +msgid "Model Name" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__name +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__name +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__name +msgid "Name" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "No defined" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__type_data__numeric +msgid "Numeric" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__orientation_fill_value +msgid "Orientation Fill Value" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local__type__out +msgid "Out" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__override_file +msgid "Override File" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__orientation_fill_value__right +msgid "Right" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__sequence +#: model:ir.model.fields,field_description:edi_local.field_edi_local_type_header__sequence +msgid "Sequence" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__size +msgid "Size" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__start +msgid "Start" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: edi_local +#: model_terms:ir.ui.view,arch_db:edi_local.edi_local_view_form +msgid "Test generate file" +msgstr "" + +#. module: edi_local +#: model_terms:ir.ui.view,arch_db:edi_local.edi_local_view_form +msgid "Test read file" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local__file_type__txt +msgid "Text" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_type_header.py:0 +#, python-format +msgid "The code must be unique." +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "The defined directory (%(dir_file)s) is not accessible." +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "" +"The document (%(document_name)s) extension is not the allowed one " +".%(file_type)s." +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "The documents were generated for %(model_name)s" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "The field %(field)s is required" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "" +"The start %(start)s and %(end)s values overlap with the %(line_name)s line.\n" +"Overlap: Start: %(start_overlap)s End: %(end_overlap)s" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "The value is longer than the allowed size (%(size)s). " +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local.py:0 +#, python-format +msgid "" +"There are no records with the conditions defined in the Apply on field." +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "" +"This is not a valid value for a date in YYYYMMDD format. Example: 20250902" +msgstr "" + +#. module: edi_local +#. odoo-python +#: code:addons/edi_local/models/edi_local_line.py:0 +#, python-format +msgid "" +"This is not a valid value for an alphanumeric character. Example: CAB159ED" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local__type +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__type +msgid "Type" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__type_data +msgid "Type Data" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__type_header +msgid "Type Header" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,help:edi_local.field_edi_local__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__value +msgid "Value" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields,field_description:edi_local.field_edi_local_line__value_fill +msgid "Value Fill" +msgstr "" + +#. module: edi_local +#: model:ir.model.fields.selection,name:edi_local.selection__edi_local_line__type__line +msgid "lines" +msgstr "" diff --git a/edi_local/models/__init__.py b/edi_local/models/__init__.py new file mode 100644 index 0000000000..be18536d47 --- /dev/null +++ b/edi_local/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import edi_local_header +from . import edi_local +from . import edi_local_line diff --git a/edi_local/models/edi_local.py b/edi_local/models/edi_local.py new file mode 100644 index 0000000000..ca4fe06093 --- /dev/null +++ b/edi_local/models/edi_local.py @@ -0,0 +1,439 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import io +import os +import textwrap +from pathlib import Path + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + +_AVAILABLE_VARIABLES = """ +# Available variables: +# - env: environment on which the action is triggered +# - record: +# When the type is 'out': it's the record on which the action is triggered. +# When the type is 'in': it's the value of the element retrieved from the +# file, usually a string. +# - model: model of the record on which the action is triggered; is a void recordset +# - time, datetime, dateutil, timezone: useful Python libraries +# - float_compare: utility function to compare floats based on specific precision +# - UserError: exception class for raising user-facing warning messages +""" + + +class EdiLocal(models.Model): + _name = "edi.local" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Edi Local" + + sequence_id = fields.Many2one("ir.sequence", string="Filename") + name = fields.Char(required=True, tracking=True) + model_id = fields.Many2one( + "ir.model", required=True, ondelete="cascade", tracking=True + ) + model_name = fields.Char( + related="model_id.model", + string="Model Name", + readonly=True, + inverse="_inverse_model_name", + ) + field_id = fields.Many2one( + "ir.model.fields", + domain="[('model', '=', model_name), " + "('ttype', 'in', ('one2many', 'many2many'))]", + ) + after_header = fields.Many2one( + "edi.local.header", + help="Defines after which header the lines are generated, " + "if not specified it is generated at the end of everything", + ) + local_line_ids = fields.One2many( + "edi.local.line", "edi_local_id", copy=True, domain=[("type", "=", "header")] + ) + line_ids = fields.One2many( + "edi.local.line", "edi_local_id", copy=True, domain=[("type", "=", "line")] + ) + description = fields.Char() + domain = fields.Char(string="Apply on", tracking=True) + enabled = fields.Boolean(default=False) + files_generated = fields.Text() + file_type = fields.Selection([("txt", "Text")], required=True, default="txt") + override_file = fields.Boolean( + default=True, + tracking=True, + help="Defines whether existing files are deleted when regenerating.", + ) + dir_file = fields.Char( + tracking=True, + help="Defines the local directory where the generated files will be stored.", + ) + type = fields.Selection( + [("in", "In"), ("out", "Out")], required=True, default="out" + ) + attachment_ids = fields.Many2many("ir.attachment") + available_variables = fields.Text(compute="_compute_available_variables") + + def _compute_available_variables(self): + available_variables = textwrap.dedent(_AVAILABLE_VARIABLES) + for local in self: + local.available_variables = available_variables + + @api.constrains("dir_file") + def _check_enabled(self): + for rec in self: + rec.valid_dir_file() + + def _inverse_model_name(self): + for rec in self: + rec.model_id = self.env["ir.model"]._get(rec.model_name) + + # ========================================================== + # GENERAL + # ========================================================== + + def get_eval_domain(self): + return self.env[self.model_name].search(safe_eval(self.domain)) + + def _message_error(self, message_error): + context = dict(self.env.context) + if context.get("generate_with_cron", False): + self.message_post(body=message_error) + else: + raise ValidationError(message_error) + + def _global_context_by_record(self, record=None): + """ + This method passes a dictionary as a context when + evaluating each of the record's values (values that match the domain). + For example: + The value of the Apply on field matches four records, + so these values are evaluated for each record, not for + each Header or Line. + """ + return {} + + def _context_by_line(self, record_line): + return {} + + def _get_values_grouped(self, headers=False, all_lines=False): + if all_lines: + values = self.local_line_ids | self.line_ids + else: + values = self.line_ids if not headers else self.local_line_ids + return values.sorted( + lambda local_line: local_line.type_header.sequence + ).grouped("type_header") + + # ========================================================== + # GENERATE FILE + # ========================================================== + + def valid_dir_file(self): + self.ensure_one() + if self.dir_file: + try: + os.makedirs(self.dir_file, exist_ok=True) + path = os.path.join(self.dir_file, ".perm_test.tmp") + with open(path, "w", encoding="utf-8") as f: + f.write("ok") + os.remove(path) + except OSError: + self._message_error( + _("The defined directory (%(dir_file)s) is not accessible.") + % { + "dir_file": self.dir_file, + } + ) + + def remove_files(self): + for file in self.files_generated.split(","): + file_path = Path(file) + if file_path.exists(): + file_path.unlink() + + def save_file(self, files): + files_generated = [] + self.valid_dir_file() + try: + for file in files: + filename, value_file = next(iter(file.items())) + path = os.path.join(self.dir_file, f"{filename}.txt") + with open(path, "w", encoding="utf-8") as f: + f.write(value_file) + files_generated.append(path) + except FileNotFoundError as ex: + self._message_error(str(ex)) + except Exception as ex: + self._message_error(ex) + return files_generated + + def get_filename(self): + return self.sequence_id.next_by_id() + + def _eval_lines(self, record, global_context_by_record): + """ + This method generates the lines according to the configuration of the Lines tab. + """ + eval_value = "" + line_grouped = self._get_values_grouped() + for _unused, values in line_grouped.items(): + for num_line, line in enumerate( + getattr(record, self.field_id.name, []), start=1 + ): + context_by_line = (global_context_by_record or {}).copy() + context_by_line.update(self._context_by_line(line)) + context_by_line.update({"num_line": num_line}) + for value in values: + eval_line = value._eval_line(line, context_by_line) + if eval_line is False: + return False + else: + eval_value += eval_line + eval_value += "\n" + return eval_value + + def _eval_headers_lines(self, record=None): + """ + This method generates the lines according + to the configuration of the Headers tab. + """ + local_line_grouped = self._get_values_grouped(headers=True) + global_context_by_record = self._global_context_by_record(record) + eval_value = "" + for type_header, values in local_line_grouped.items(): + for value in values: + eval_line = value._eval_line(record, global_context_by_record) + if eval_line is False: + return False + else: + eval_value += eval_line + + if self.after_header and self.after_header == type_header and self.field_id: + eval_value += "\n" + eval_value_line = self._eval_lines(record, global_context_by_record) + if eval_value_line is False: + return False + else: + eval_value += eval_value_line + eval_value += "\n" + return eval_value + + def _post_generate_file(self, record): + """ + params: + record: record that matches the configured domain + + This method is executed after generating a file for + one of the records that matches the configured domain. + For example: + When generating a file for an invoice, a 'Generated file' + field is set to allow the user to filter. + """ + pass + + def post_generate_file(self, record): + context = dict(self.env.context) + if not context.get("test_generate_file", False): + self._post_generate_file(record) + + def generate_file_txt(self, records): + files = [] + for record in records: + filename = self.get_filename() + eval_value = self._eval_headers_lines(record) + if eval_value: + files.append({filename: textwrap.dedent(eval_value).lstrip("\n")}) + self.post_generate_file(record) + return files + + def valid_generate_file(self): + self.ensure_one() + if not self.sequence_id: + self._message_error(_("Define a filename")) + + def generate_file(self): + context = dict(self.env.context) + for local in self: + if local.enabled or context.get("test_generate_file", False): + local.valid_before_read_or_generate_file() + local.valid_generate_file() + func_name = f"generate_file_{local.file_type}" + func_export_type = getattr(local, func_name, None) + if func_export_type: + try: + records = local.get_eval_domain() + if records: + result_files = func_export_type(records) + if result_files and not context.get( + "test_generate_file", False + ): + files_generated = local.save_file(result_files) + if self.override_file: + if self.files_generated: + self.remove_files() + self.files_generated = ",".join(files_generated) + else: + self.files_generated += "," + ",".join( + files_generated + ) + local.message_post( + body=_("Generated files:%(files_generated)s") + % { + "files_generated": "
".join( + files_generated + ) + }, + body_is_html=True, + ) + else: + continue + else: + local._message_error( + _( + "There are no records with " + "the conditions defined in the Apply on field." + ) + ) + except Exception as ex: + local._message_error(ex) + else: + local._message_error( + _( + "I do not generate documents of type %(document_type)s " + "because the function with structure " + " %(func_structure)s does not exist." + ) + % { + "document_type": local.file_type, + "func_structure": func_name, + } + ) + + # ========================================================== + # READ IMPORT FILE + # ========================================================== + + @api.constrains("attachment_ids", "file_type") + def _check_file(self): + for local in self: + for att in local.attachment_ids: + name = (att.name or "").lower() + ext = os.path.splitext(name)[1] + if ext != f".{local.file_type.lower()}": + raise ValidationError( + _( + "The document (%(document_name)s) extension is " + "not the allowed one .%(file_type)s." + ) + % { + "document_name": att.name, + "file_type": local.file_type, + } + ) + + def _post_read_import_file(self, value_list): + pass + + def post_read_import_file(self, value_list): + context = dict(self.env.context) + if not context.get("test_read_import_file", False): + self._post_read_import_file(value_list) + model_name = self.model_name.replace(".", " ").capitalize() + self.message_post( + body=_("The documents were generated for %(model_name)s") + % {"model_name": f"
{model_name}"}, + body_is_html=True, + ) + + def valid_read_file(self): + pass + + def read_file_txt(self, **values): + if values.get("bs4_data", False): + file_import_raw = base64.b64decode(values["bs4_data"]) + file_text = io.TextIOWrapper( + io.BytesIO(file_import_raw), + encoding=values.get("encoding", "utf-8"), + errors="replace", + ) + return file_text.readlines() + return [] + + def read_import_file_txt(self): + self.ensure_one() + lines = self._get_values_grouped(all_lines=True) + global_context_by_record = self._global_context_by_record() + value_list = [] + for attachment in self.attachment_ids: + lines_import = self.read_file_txt(**{"bs4_data": attachment.datas}) + field_name = self.field_id.name + item_line = 0 + value_dict = {} + for type_header, values in lines.items(): + line = lines_import[item_line].rstrip("\n") + if not value_dict.get(type_header.code, False): + value_dict[type_header.code] = {} + for value in values: + record_value = value._parse_value(line) + eval_line = value._eval_line(record_value, global_context_by_record) + if value.type == "line": + if not value_dict[type_header.code].get(field_name, False): + value_dict[type_header.code][field_name] = {} + value_dict[type_header.code][field_name].update(eval_line) + else: + value_dict[type_header.code].update(eval_line) + item_line += 1 + value_list.append(value_dict) + return value_list + + def read_import_file(self): + context = dict(self.env.context) + value_list = [] + for local in self: + if local.enabled or context.get("test_read_import_file", False): + local.valid_before_read_or_generate_file() + local.valid_read_file() + func_name = f"read_import_file_{local.file_type}" + func_import_type = getattr(local, func_name, None) + if func_import_type: + try: + value_list = func_import_type() + except Exception as ex: + local._message_error(ex) + else: + local._message_error( + _( + "Documents of type %(document_type)s " + "were not imported " + "because the function with structure " + " %(func_structure)s does not exist." + ) + % { + "document_type": local.file_type, + "func_structure": func_name, + } + ) + local.post_read_import_file(value_list) + + def valid_before_read_or_generate_file(self): + self.ensure_one() + if not self.local_line_ids: + self._message_error(_("Define lines before generating a file")) + + def get_domain_cron(self): + return [ + ("enabled", "=", True), + ("type", "=", "out"), + "|", + ("local_line_ids", "!=", False), + ("line_ids", "!=", False), + ] + + def generate_file_cron(self): + for local in self.search(self.get_domain_cron()): + if local.get_eval_domain(): + local.generate_file() diff --git a/edi_local/models/edi_local_header.py b/edi_local/models/edi_local_header.py new file mode 100644 index 0000000000..1e158fe7c2 --- /dev/null +++ b/edi_local/models/edi_local_header.py @@ -0,0 +1,26 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class EdiLocalHeader(models.Model): + _name = "edi.local.header" + _description = "Edi Local Header" + + sequence = fields.Integer() + code = fields.Char(required=True) + name = fields.Char(required=True) + + @api.constrains("code") + def _check_code(self): + for local_header in self: + if self.search_count( + [ + ("code", "=", local_header.code), + ("id", "!=", local_header.id), + ], + limit=1, + ): + raise ValidationError(_("The code must be unique.")) diff --git a/edi_local/models/edi_local_line.py b/edi_local/models/edi_local_line.py new file mode 100644 index 0000000000..5fc5f89660 --- /dev/null +++ b/edi_local/models/edi_local_line.py @@ -0,0 +1,410 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import inspect + +from pytz import timezone + +from odoo import _, api, fields, models, tools +from odoo.tools.float_utils import float_compare +from odoo.tools.safe_eval import safe_eval, test_python_expr + +from ..utils import is_alphanumeric, is_date, is_numeric + + +class EdiLocalLine(models.Model): + _name = "edi.local.line" + _description = "Edi local line" + + edi_local_id = fields.Many2one("edi.local") + model_name = fields.Char(related="edi_local_id.model_name") + model_id = fields.Many2one("ir.model", related="edi_local_id.model_id") + sequence = fields.Integer() + type = fields.Selection( + [ + ("header", "Header"), + ("line", "lines"), + ], + default="header", + help="", + ) + type_header = fields.Many2one( + "edi.local.header", + help="Defines after which header the lines are generated, " + "if not specified it is generated at the end of everything", + ) + name = fields.Char(required=True) + description = fields.Char(help="A description of the value the field accepts.") + type_data = fields.Selection( + [ + ("alphanumeric", "Alphanumeric"), + ("numeric", "Numeric"), + ("date", "Date"), + ("general", "General"), + ], + default="alphanumeric", + help="Defines the data type of the value\n" + "Alphanumeric: Only letters and numbers are allowed. Example: Alpha12pha345\n" + "Numeric: Only numbers are allowed. Example: 12345\n" + "Date: Only dates are allowed. Example: 20251112\n" + "General: Any value is allowed. Example: Alpha1_2pha345-*_", + ) + start = fields.Integer( + help="Defines the position from which counting will begin based on " + "the size value. By default, this is the last position " + "of the previous line in the same header.", + store=True, + ) + size = fields.Integer( + help="Defines the field size. Example: 5 positions", required=True, default=1 + ) + end = fields.Integer(compute="_compute_end", store=True) + decimal = fields.Integer( + default=0, + help="Defines the number of decimal places in the " + "number. These decimal places are taken into " + "account in the length (start + size)", + ) + is_required = fields.Boolean( + default=True, + ) + fill_value = fields.Boolean( + default=False, + help="Defines whether the value size is filled with " + "a specific character; if the default character " + "is not defined, it is filled with a space.", + ) + value_fill = fields.Char( + help="Defines the value to be filled, by default it is a space." + ) + orientation_fill_value = fields.Selection( + [("left", "Left"), ("center", "Center"), ("right", "Right")], + default="right", + help="Defines the orientation in which " "the filling will be carried out.", + ) + value = fields.Text() + + @api.depends("start", "size") + def _compute_end(self): + for line in self: + line.end = line.start + line.size + + @api.constrains("start", "size") + def _check_start(self): + for line in self: + overlap = self.search_read( + [ + ("type", "=", line.type), + ("type_header", "=", line.type_header.id), + ("start", "<", line.end), + ("model_name", "=", line.edi_local_id.model_name), + ("edi_local_id", "=", line.edi_local_id.id), + ("end", ">", line.start), + ("id", "!=", line.id), + ], + ["name", "start", "end"], + limit=1, + ) + if overlap: + self.edi_local_id._message_error( + _( + "The start %(start)s and %(end)s values overlap with the " + "%(line_name)s line." + "\nOverlap: Start: %(start_overlap)s End: %(end_overlap)s" + ) + % { + "start": line.start, + "end": line.end, + "line_name": overlap[0]["name"], + "start_overlap": overlap[0]["start"], + "end_overlap": overlap[0]["end"], + } + ) + + @api.onchange("value") + def _onchange_value(self): + for local in self: + local._normalize_code() + if local.value: + local._test_python_expr() + + def _normalize_code(self): + format_value = self.value or "" + format_value = format_value.replace("\r\n", "\n") + format_value = format_value.expandtabs(4) + self.value = inspect.cleandoc(format_value).strip() + + def _not_check_value(self, record, value): + return False + + def _show_message_error_line(self, record, value, message_error): + self.edi_local_id._message_error( + _( + "\nType: %(type)s " + "\nValue: %(value)s " + "\nField: %(field_name)s " + "\nExpression: %(expression)s " + "\nRecord [%(model_name)s]: %(record_name)s " + "\nError: %(message)s" + ) + % { + "type": self.type.capitalize(), + "field_name": self.name, + "model_name": self.model_id.name, + "record_name": getattr(record, "name", ""), + "value": value if value else _("No defined"), + "expression": self.value, + "message": message_error, + } + ) + + def _valid_is_numeric(self, **values): + value = values.get("value") + partial_integer = self.size - self.decimal + partial_decimal = self.decimal + valid = True + if not is_numeric( + value=value, + partial_integer=values.get("partial_integer", partial_integer), + partial_decimal=values.get("partial_decimal", partial_decimal), + ) or (values.get("is_digit", False) and not value.isdigit()): + valid = False + self._show_message_error_line( + self, + value, + _( + "It is not a valid number with %(partial_integer)s " + "whole digits and %(partial_decimal)s decimal places." + ) + % { + "partial_integer": partial_integer, + "partial_decimal": partial_decimal, + }, + ) + return valid + + def _valid_is_alphanumeric(self, **values): + value = values.get("value") + valid = True + if not is_alphanumeric( + value=value, + allow_character=" " + if self.fill_value and not self.value_fill + else self.value_fill, + ): + valid = False + self._show_message_error_line( + self, + value, + _( + "This is not a valid value for an alphanumeric character. " + "Example: CAB159ED" + ), + ) + return valid + + def _valid_is_date(self, **values): + value = values.get("value") + valid = True + if not is_date(value=value): + valid = False + self._show_message_error_line( + self, + value, + _( + "This is not a valid value for a date in YYYYMMDD format. " + "Example: 20250902" + ), + ) + return valid + + def _check_value(self, record, value): + check_valid = True + if not self.is_required and not value: + return check_valid + value_size = ( + self.size + 1 + if self.type_data == "numeric" and self.decimal > 0 + else self.size + ) + if len(value) > value_size: + self._show_message_error_line( + record, + value, + _("The value is longer than the allowed size (%(size)s). ") + % { + "size": value_size, + }, + ) + check_valid = False + elif self.is_required and not value: + self.edi_local_id._message_error( + _("The field %(field)s is required") % {"field": self.name} + ) + check_valid = False + elif self.type_data == "numeric": + check_valid = self._valid_is_numeric(**{"value": value}) + elif self.type_data == "alphanumeric": + check_valid = self._valid_is_alphanumeric(**{"value": value}) + elif self.type_data == "date": + check_valid = self._valid_is_date(**{"value": value}) + return check_valid + + def check_value(self, record, value): + if self._not_check_value(record, value): + return True + return self._check_value(record, value) + + def _get_eval_context(self, eval_context=None): + return { + # orm + "env": self.env, + "model": self.env[self.model_name], + # record + "record": None, + # tools + "time": tools.safe_eval.time, + "datetime": tools.safe_eval.datetime, + "dateutil": tools.safe_eval.dateutil, + "timezone": timezone, + "float_compare": float_compare, + } | (eval_context or {}) + + def _test_python_expr(self): + # Evaluating expression syntax + if self.value: + msg = test_python_expr(expr=self.value, mode="eval") + if msg: + self.edi_local_id._message_error(msg) + return True + + def _trunc_value(self, value): + if self.type_data == "alphanumeric": + return value[0 : self.size] + return value + + def _fill_value_numeric(self, **values): + eval_value = values.get("eval_value", None) + size = values.get("size", None) + number_str = str(eval_value) + if "." in number_str: + integer, decimal = number_str.split(".", 1) + else: + integer, decimal = number_str, "" + partial_integer = integer.zfill(size) + if self.decimal: + partial_decimal = decimal.ljust(self.decimal, "0")[: self.decimal] + value_complete = f"{partial_integer}.{partial_decimal}" + else: + value_complete = partial_integer + return value_complete + + def _fill_value_alphanumeric(self, **values): + eval_value = values.get("eval_value", None) + size = values.get("size", None) + if self.orientation_fill_value == "left": + value_complete = ( + str(eval_value).rjust(size, str(self.value_fill)) + if self.value_fill + else str(eval_value).rjust(size) + ) + elif self.orientation_fill_value == "center": + value_complete = ( + str(eval_value).center(size, str(self.value_fill)) + if self.value_fill + else str(eval_value).center(size) + ) + else: + value_complete = ( + str(eval_value).ljust(size, str(self.value_fill)) + if self.value_fill + else str(eval_value).ljust(size) + ) + return value_complete + + def _fill_value_date(self, **values): + eval_value = values.get("eval_value", None) + size = values.get("size", None) + if self.value_fill: + value_complete = str(eval_value).rjust(size, str(self.value_fill)) + else: + value_complete = str(eval_value).rjust(size) + return value_complete + + def fill_value_by_size(self, eval_value): + size = self.size - self.decimal + if self.type_data == "alphanumeric": + value_complete = self._fill_value_alphanumeric( + **{"eval_value": eval_value, "size": size} + ) + elif self.type_data == "numeric": + value_complete = self._fill_value_numeric( + **{"eval_value": eval_value, "size": size} + ) + elif self.type_data == "date": + value_complete = self._fill_value_date( + **{"eval_value": eval_value, "size": size} + ) + else: + value_complete = eval_value + return value_complete + + def _eval_value(self, record, global_context_by_record): + self._normalize_code() + self._test_python_expr() + try: + global_context_by_record.update( + { + "record": record, + } + ) + if not self.value: + return "" if self.edi_local_id.type == "out" else {self.name: record} + + value = safe_eval( + self.value, + mode="eval", + nocopy=True, + globals_dict=self._get_eval_context( + eval_context=global_context_by_record + ), + ) + if self.edi_local_id.type == "out": + result = self._trunc_value(value) + if self.fill_value and len(str(result)) < self.size: + result = self.fill_value_by_size(result) + result_check = self.check_value(record, result) + if not result_check: + return False + else: + result = {self.name: value} + return result + except TypeError as e: + self._show_message_error_line(record, None, str(e)) + + def _eval_line(self, record, global_context_by_record): + context = dict(self.env.context) + eval_value = self.with_context(**context)._eval_value( + record, global_context_by_record + ) + return eval_value + + def _parse_value(self, value): + self.ensure_one() + parse_value = value[self.start - 1 : self.end - 1] + parse_value = parse_value.replace(self.value_fill or " ", "") + if self.type_data == "numeric" and parse_value: + self._valid_is_numeric(**{"value": parse_value, "is_digit": True}) + parse_entire = parse_value[0 : self.size] + partial_decimal = False + if self.decimal: + partial_decimal = parse_value[self.size : self.size + self.decimal :] + parse_value = float( + f"{parse_entire}.{partial_decimal or 0}" + if self.decimal + else parse_entire + ) + elif self.type_data == "alphanumeric" and parse_value: + self._valid_is_alphanumeric(**{"value": parse_value}) + elif self.type_data == "date" and parse_value: + self._valid_is_date(**{"value": parse_value}) + return parse_value diff --git a/edi_local/pyproject.toml b/edi_local/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/edi_local/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_local/readme/CONTRIBUTORS.md b/edi_local/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..ab4e0fb41b --- /dev/null +++ b/edi_local/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Binhex] (https://www.binhex.cloud): + - Edilio Escalona Almira diff --git a/edi_local/readme/DESCRIPTION.md b/edi_local/readme/DESCRIPTION.md new file mode 100644 index 0000000000..569c2db076 --- /dev/null +++ b/edi_local/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +The module allows, through configuration, the following: + +1. Generating .txt files. +2. Reading .txt files. diff --git a/edi_local/readme/ROADMAP.md b/edi_local/readme/ROADMAP.md new file mode 100644 index 0000000000..7ac99240db --- /dev/null +++ b/edi_local/readme/ROADMAP.md @@ -0,0 +1 @@ +Add a new xml type and make the necessary modifications to generate and read files of that type. diff --git a/edi_local/readme/USAGE.md b/edi_local/readme/USAGE.md new file mode 100644 index 0000000000..deaec40cf7 --- /dev/null +++ b/edi_local/readme/USAGE.md @@ -0,0 +1,19 @@ +Create headers for the configurations +----------------------------------------- +These headers allow us to have a configuration order independent of the order of the configuration lines. + +Example: +Header X - Sequence 1 +Header Y - Sequence 2 +Lines (Configuration): +- Header Y / Line 1 / Sequence 3 +- Header X / Line 2 / Sequence 5 +- Header Y / Line 3 / Sequence 1 + +Therefore, by grouping the lines by header, line 2 will be obtained first, then line 3, and then line 1, in that order. + +1. Go to Settings > Technical > EDI > Edi local header +2. Create a new header +3. Save + + diff --git a/edi_local/security/ir.model.access.csv b/edi_local/security/ir.model.access.csv new file mode 100644 index 0000000000..277ea08131 --- /dev/null +++ b/edi_local/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_edi_local_manager,edi_local.edi_local,model_edi_local,base.group_user,1,1,1,1 +access_edi_local_line_manager,edi_local.edi_local.line,model_edi_local_line,base.group_user,1,1,1,1 +access_edi_local_header_manager,edi_local.edi_local.header,model_edi_local_header,base.group_user,1,1,1,1 diff --git a/edi_local/static/description/icon.png b/edi_local/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/edi_local/static/description/icon.png differ diff --git a/edi_local/static/description/index.html b/edi_local/static/description/index.html new file mode 100644 index 0000000000..02cf2d7f9a --- /dev/null +++ b/edi_local/static/description/index.html @@ -0,0 +1,462 @@ + + + + + +Edi local + + + +
+

Edi local

+ + +

Beta License: AGPL-3 OCA/edi Translate me on Weblate Try me on Runboat

+

The module allows, through configuration, the following:

+
    +
  1. Generating .txt files.
  2. +
  3. Reading .txt files.
  4. +
+

Table of contents

+ +
+

Usage

+
+

Create headers for the configurations

+

These headers allow us to have a configuration order independent of the +order of the configuration lines.

+

Example: Header X - Sequence 1 Header Y - Sequence 2 Lines +(Configuration):

+
    +
  • Header Y / Line 1 / Sequence 3
  • +
  • Header X / Line 2 / Sequence 5
  • +
  • Header Y / Line 3 / Sequence 1
  • +
+

Therefore, by grouping the lines by header, line 2 will be obtained +first, then line 3, and then line 1, in that order.

+
    +
  1. Go to Settings > Technical > EDI > Edi local header
  2. +
  3. Create a new header
  4. +
  5. Save
  6. +
+
+
+
+

Known issues / Roadmap

+

Add a new xml type and make the necessary modifications to generate and +read files of that type.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Binhex
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/edi project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/edi_local/utils.py b/edi_local/utils.py new file mode 100644 index 0000000000..4becc0f88c --- /dev/null +++ b/edi_local/utils.py @@ -0,0 +1,28 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import re + + +def is_numeric(value, partial_integer=8, partial_decimal=None): + partial_integer = partial_integer if "-" not in value else partial_integer - 1 + if partial_decimal: + regex = re.compile( + rf"^-?\d{{1,{partial_integer}}}(?:\.\d{{{partial_decimal}}})?$" + ) + else: + regex = re.compile(rf"^-?\d{{1,{partial_integer}}}$") + return regex.match(str(value)) + + +def is_alphanumeric(value, allow_character=None): + ex_regex = "^[A-Za-z0-9 ]+$" + if allow_character: + ex_regex = rf"^[A-Za-z0-9 {allow_character}]+$" + regex = re.compile(ex_regex) + return regex.match(str(value)) + + +def is_date(value): + regex = re.compile(r"^\d{4}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])$") + return regex.match(str(value)) diff --git a/edi_local/views/edi_local_header_views.xml b/edi_local/views/edi_local_header_views.xml new file mode 100644 index 0000000000..866930cc57 --- /dev/null +++ b/edi_local/views/edi_local_header_views.xml @@ -0,0 +1,23 @@ + + + + + edi.local.header.view.tree + edi.local.header + + + + + + + + + + + Edi local header + edi.local.header + tree,form + + + + diff --git a/edi_local/views/edi_local_line_views.xml b/edi_local/views/edi_local_line_views.xml new file mode 100644 index 0000000000..3d4d332ec1 --- /dev/null +++ b/edi_local/views/edi_local_line_views.xml @@ -0,0 +1,62 @@ + + + + edi.local.line.view.tree + edi.local.line + + + + + + + + + + + + + + + + + + + + edi.local.line.view.form + edi.local.line + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
diff --git a/edi_local/views/edi_local_menus_views.xml b/edi_local/views/edi_local_menus_views.xml new file mode 100644 index 0000000000..caa4608936 --- /dev/null +++ b/edi_local/views/edi_local_menus_views.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/edi_local/views/edi_local_views.xml b/edi_local/views/edi_local_views.xml new file mode 100644 index 0000000000..1c4cbb95fb --- /dev/null +++ b/edi_local/views/edi_local_views.xml @@ -0,0 +1,158 @@ + + + + + edi.local.view.tree + edi.local + + + + + + + + + + + + + + edi.local.view.form + edi.local + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ +
+ + +
+
+
+
+ + + Edi local + edi.local + tree,form + + + +