diff --git a/edi_gs1_oca/README.rst b/edi_gs1_oca/README.rst new file mode 100644 index 000000000..deeb0aec8 --- /dev/null +++ b/edi_gs1_oca/README.rst @@ -0,0 +1,89 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============ +Base GS1 EDI +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a5ab15a8b1dea2fd4da5955af650a8db9296c1a96cb53d4d7d98188d059a14da + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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--framework-lightgray.png?logo=github + :target: https://github.com/OCA/edi-framework/tree/19.0/edi_gs1_oca + :alt: OCA/edi-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-framework-19-0/edi-framework-19-0-edi_gs1_oca + :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-framework&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This is the base module for the implementation of the `GS1 -The Global +Language of Business `__ standard. + +This module contains methods to generate and parse XML files compliant +with this standard. + +Beside basic machinery it provides common components to generate common +GS1 schema elements. Namely: + + - StandardBusinessDocumentHeader + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* ACSONE + +Contributors +------------ + +- Simone Orsi + +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-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_gs1_oca/TODO b/edi_gs1_oca/TODO new file mode 100644 index 000000000..caf440dd3 --- /dev/null +++ b/edi_gs1_oca/TODO @@ -0,0 +1,13 @@ +Inbound Instruction + +- [x] allow skipping keys / elements by returning none +- [x] check UOM code handling +- [x] finish mapping of what we know +- [] draft RF-EFR module w/ tests +- [] allow export from PO +- [] handle state of export +- [] use storage backend to store it +- [] add lookup of acknowledge message +- [] add new field code to UOM +- [] draft send ack +- [] draft Outbound Notification parse diff --git a/edi_gs1_oca/__init__.py b/edi_gs1_oca/__init__.py new file mode 100644 index 000000000..cc6b6354a --- /dev/null +++ b/edi_gs1_oca/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import post_init_hook diff --git a/edi_gs1_oca/__manifest__.py b/edi_gs1_oca/__manifest__.py new file mode 100644 index 000000000..24ada98ef --- /dev/null +++ b/edi_gs1_oca/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Base GS1 EDI", + "summary": """ + Base module for GS1 standard EDI exchange""", + "version": "19.0.1.0.0", + "development_status": "Beta", + "license": "AGPL-3", + "website": "https://github.com/OCA/edi-framework", + "author": "ACSONE,Odoo Community Association (OCA)", + "depends": ["edi_core_oca", "uom"], + "external_dependencies": {"python": ["xmlschema", "xmlunittest"]}, + "data": [ + "security/gs1_backend_acl.xml", + "data/gs1_backend_data.xml", + "data/ack_in_exchange_type_data.xml", + "data/ack_out_exchange_type_data.xml", + "data/business_header_qweb_template.xml", + "data/contact_details_qweb_template.xml", + "views/res_partner.xml", + "views/edi_backend.xml", + "views/uom_uom.xml", + ], + "post_init_hook": "post_init_hook", +} diff --git a/edi_gs1_oca/data/ack_in_exchange_type_data.xml b/edi_gs1_oca/data/ack_in_exchange_type_data.xml new file mode 100644 index 000000000..e63b86fbe --- /dev/null +++ b/edi_gs1_oca/data/ack_in_exchange_type_data.xml @@ -0,0 +1,25 @@ + + + + + GS1 ApplicationReceiptAcknowledgement + applicationReceiptAcknowledgement_in + input + + + xml + + + +gs1: + schema_path: edi_gs1_oca:static/schemas/gs1/ecom/ApplicationReceiptAcknowledgement.xsd + + + diff --git a/edi_gs1_oca/data/ack_out_exchange_type_data.xml b/edi_gs1_oca/data/ack_out_exchange_type_data.xml new file mode 100644 index 000000000..edc47991c --- /dev/null +++ b/edi_gs1_oca/data/ack_out_exchange_type_data.xml @@ -0,0 +1,17 @@ + + + + + GS1 ApplicationReceiptAcknowledgement + applicationReceiptAcknowledgement_out + output + + {record_name}-{type.code}-{dt} + xml + + +gs1: + schema_path: edi_gs1_oca:static/schemas/gs1/ecom/ApplicationReceiptAcknowledgement.xsd + + + diff --git a/edi_gs1_oca/data/business_header_qweb_template.xml b/edi_gs1_oca/data/business_header_qweb_template.xml new file mode 100644 index 000000000..30f104fc0 --- /dev/null +++ b/edi_gs1_oca/data/business_header_qweb_template.xml @@ -0,0 +1,65 @@ + + + + + diff --git a/edi_gs1_oca/data/contact_details_qweb_template.xml b/edi_gs1_oca/data/contact_details_qweb_template.xml new file mode 100644 index 000000000..8cbe00ef1 --- /dev/null +++ b/edi_gs1_oca/data/contact_details_qweb_template.xml @@ -0,0 +1,47 @@ + + + + diff --git a/edi_gs1_oca/data/gs1_backend_data.xml b/edi_gs1_oca/data/gs1_backend_data.xml new file mode 100644 index 000000000..83f1a0b55 --- /dev/null +++ b/edi_gs1_oca/data/gs1_backend_data.xml @@ -0,0 +1,11 @@ + + + + GS1 + gs1 + + + GS1 default + + + diff --git a/edi_gs1_oca/hooks.py b/edi_gs1_oca/hooks.py new file mode 100644 index 000000000..c6dbb045f --- /dev/null +++ b/edi_gs1_oca/hooks.py @@ -0,0 +1,11 @@ +# Copyright 2021 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +def post_init_hook(env): + """ + Hook used to init automatically some GS1 code on existing UoM + :param env: Odoo Environment + :return: + """ + env["uom.uom"]._execute_gs1_map_code() diff --git a/edi_gs1_oca/models/__init__.py b/edi_gs1_oca/models/__init__.py new file mode 100644 index 000000000..a0074d0b1 --- /dev/null +++ b/edi_gs1_oca/models/__init__.py @@ -0,0 +1,7 @@ +from . import edi_gs1_xml +from . import edi_gs1_output +from . import edi_gs1_input +from . import edi_gs1_handler +from . import edi_backend +from . import res_partner +from . import uom_uom diff --git a/edi_gs1_oca/models/edi_backend.py b/edi_gs1_oca/models/edi_backend.py new file mode 100644 index 000000000..a642e07e7 --- /dev/null +++ b/edi_gs1_oca/models/edi_backend.py @@ -0,0 +1,21 @@ +# Copyright 2020 ACSONE SA +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class EDIBackend(models.Model): + _inherit = "edi.backend" + + # TODO: find another way to configure this w/out polluting edi backend + lsp_partner_id = fields.Many2one( + string="Logistic Services Provider (LSP)", + comodel_name="res.partner", + domain=[("is_lsp", "=", True)], + ) + lsc_partner_id = fields.Many2one( + string="Logistic Services Client (LSC)", + comodel_name="res.partner", + default=lambda self: self.env.ref("base.main_partner").id, + ) diff --git a/edi_gs1_oca/models/edi_gs1_handler.py b/edi_gs1_oca/models/edi_gs1_handler.py new file mode 100644 index 000000000..bff5164cc --- /dev/null +++ b/edi_gs1_oca/models/edi_gs1_handler.py @@ -0,0 +1,81 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import exceptions, models + +from odoo.addons.edi_core_oca.exceptions import EDIValidationError + + +class EdiGs1HandlerValidate(models.AbstractModel): + """Validate GS1 exchange files against their XSD schema. + + Wired on exchange types via ``input_validate_model_id`` / + ``output_validate_model_id``. The schema is read from the exchange type + advanced settings:: + + gs1: + schema_path: edi_gs1_oca:static/schemas/.../Foo.xsd + """ + + _name = "edi.gs1.handler.validate" + _inherit = ["edi.oca.handler.input.validate", "edi.oca.handler.output.validate"] + _description = "GS1 EDI XSD validation handler" + + def _gs1_validate(self, exchange_record, value=None): + settings = exchange_record.type_id.get_settings() or {} + schema_path = settings.get("gs1", {}).get("schema_path") + if not schema_path: + return None + content = value + if content is None: + content = exchange_record._get_file_content() + if isinstance(content, bytes): + content = content.decode() + error = self.env["edi.gs1.xml"].validate(schema_path, content) + if error: + raise EDIValidationError(error) + return None + + def input_validate(self, exchange_record, value=None, **kw): + return self._gs1_validate(exchange_record, value=value) + + def output_validate(self, exchange_record, value=None, **kw): + return self._gs1_validate(exchange_record, value=value) + + +class EdiGs1HandlerApplicationReceiptAck(models.AbstractModel): + """Process GS1 applicationReceiptAcknowledgement. + + Wired on the input ack exchange type via ``process_model_id``. + """ + + _name = "edi.gs1.handler.application.receipt.ack" + _inherit = ["edi.oca.handler.process", "edi.gs1.input.mixin"] + _description = "GS1 process applicationReceiptAcknowledgement" + + def process(self, exchange_record, **kw): + data = self._parse(exchange_record) + self._process_data(data, exchange_record) + return self.env._("applicationReceiptAcknowledgement processed") + + def _process_data(self, data, exchange_record): + if not self.is_ok(data): + # TODO: give a proper message based on the real state + # options: ERROR, RECEIVED, WARNING + raise exceptions.ValidationError(self.env._("Exchange not received")) + + def is_ok(self, data): + return self._get_status(data) == "RECEIVED" + + def _get_status(self, data): + try: + ack = data["applicationReceiptAcknowledgement"][0] + except IndexError as exc: + # Maybe raise an EDIValidationError? + raise exceptions.ValidationError( + self.env._("applicationReceiptAcknowledgement element not found!") + ) from exc + status_wrapper = ack["applicationResponseDocumentLevel"][0] + # options: ERROR, RECEIVED, WARNING + return status_wrapper["applicationResponseStatusCode"] diff --git a/edi_gs1_oca/models/edi_gs1_input.py b/edi_gs1_oca/models/edi_gs1_input.py new file mode 100644 index 000000000..710c3de5d --- /dev/null +++ b/edi_gs1_oca/models/edi_gs1_input.py @@ -0,0 +1,28 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class EdiGs1InputMixin(models.AbstractModel): + """Common GS1 input processing mixin. + + Replaces the old ``edi.gs1.input.mixin`` component. Concrete processors + inherit this mixin together with ``edi.oca.handler.process``. + """ + + _name = "edi.gs1.input.mixin" + _description = "GS1 EDI input mixin" + + def _get_schema_path(self, exchange_record): + settings = exchange_record.type_id.get_settings() or {} + return settings.get("gs1", {}).get("schema_path") + + def _parse(self, exchange_record): + schema_path = self._get_schema_path(exchange_record) + content = exchange_record._get_file_content() + return self.env["edi.gs1.xml"].parse_xml(schema_path, content) + + def _process_data(self, data, exchange_record): + raise NotImplementedError() diff --git a/edi_gs1_oca/models/edi_gs1_output.py b/edi_gs1_oca/models/edi_gs1_output.py new file mode 100644 index 000000000..61308416f --- /dev/null +++ b/edi_gs1_oca/models/edi_gs1_output.py @@ -0,0 +1,74 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import datetime + +import pytz + +from odoo import fields, models +from odoo.tools import DotDict + +from ..utils import xml_purge_nswrapper + + +class EdiGs1OutputMixin(models.AbstractModel): + """Common GS1 output generation mixin. + + Replaces the old ``edi.output.info.mixin`` (info provider) and the + ``edi.exchange.template.output`` QWeb rendering, both dropped in the + new EDI OCA architecture. Concrete generators inherit this mixin + together with ``edi.oca.handler.generate``. + """ + + _name = "edi.gs1.output.mixin" + _description = "GS1 EDI output mixin" + + # --- info provider (ported from edi.info.provider.mixin) --- + def generate_info(self, exchange_record, **kw): + """Generate and return data for output info. + + :return: odoo.tools.DotDict + """ + return DotDict(self._generate_info(exchange_record, **kw)) + + def _generate_info(self, exchange_record, **kw): + raise NotImplementedError("You must provide `_generate_info`") + + @staticmethod + def _utc_now(): + return datetime.datetime.utcnow().isoformat() + + @staticmethod + def date_to_string(dt, utc=True): + if utc: + dt = dt.astimezone(pytz.UTC) + return fields.Date.to_string(dt) + + def _document_action_code(self, replace_existing=False): + # Brand new file -> "ADD", otherwise replacing an existing one + return "CHANGE_BY_REFRESH" if replace_existing else "ADD" + + # --- template rendering (ported from edi.exchange.template.output) --- + def _get_render_values(self, exchange_record, **kw): + """Collect values to render a GS1 QWeb template.""" + values = { + "exchange_record": exchange_record, + "record": exchange_record.record, + "backend": exchange_record.backend_id, + # Default identifier to the exchange record one + "instance_identifier": exchange_record.identifier, + "doc_type": None, + "utc_now": self._utc_now, + "date_to_string": self.date_to_string, + "render_edi_template": self._render_edi_template, + "info": {}, + } + values.update(kw) + return values + + def _render_edi_template(self, exchange_record, xmlid, **kw): + """Render a GS1 QWeb template and purge the `nswrapper` helpers.""" + values = self._get_render_values(exchange_record, **kw) + output = self.env["ir.qweb"]._render(xmlid, values) + return xml_purge_nswrapper(output) diff --git a/edi_gs1_oca/models/edi_gs1_xml.py b/edi_gs1_oca/models/edi_gs1_xml.py new file mode 100644 index 000000000..df244e593 --- /dev/null +++ b/edi_gs1_oca/models/edi_gs1_xml.py @@ -0,0 +1,63 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import io +from contextlib import closing + +import xmlschema + +from odoo import models, tools +from odoo.tools import DotDict + + +class EdiGs1Xml(models.AbstractModel): + """Validate and parse XML against a bundled XSD schema. + + Replaces the ``edi.xml`` component from the old ``edi_xml`` module + (dropped in the new EDI OCA architecture). Schema paths use the + ``module:relative/path.xsd`` notation. + """ + + _name = "edi.gs1.xml" + _description = "GS1 EDI XML helper" + + @staticmethod + def _resolve_schema_path(schema_path): + try: + mod_name, path = schema_path.split(":") + except ValueError as exc: + raise ValueError("Path must be in the form `module:path`") from exc + return tools.file_path(f"{mod_name}/{path}") + + def _get_schema(self, schema_path): + return xmlschema.XMLSchema(self._resolve_schema_path(schema_path)) + + def parse_xml(self, schema_path, file_content, **kw): + """Read XML content and return a data dict. + + :param schema_path: schema path as ``module:path`` + :param file_content: str of XML file + :return: odoo.tools.DotDict with final data + """ + schema = self._get_schema(schema_path) + with closing(io.StringIO(file_content)) as fd: + return DotDict(schema.to_dict(fd, **kw)) + + def validate(self, schema_path, xml_content, raise_on_fail=False): + """Validate XML content against the XSD schema. + + :param schema_path: schema path as ``module:path`` + :param xml_content: str containing xml data to validate + :param raise_on_fail: turn on/off validation error exception on fail + :return: + * None if validation is ok + * error string if `raise_on_fail` is False + """ + schema = self._get_schema(schema_path) + try: + return schema.validate(xml_content) + except xmlschema.validators.exceptions.XMLSchemaValidationError as err: + if raise_on_fail: + raise + return str(err) diff --git a/edi_gs1_oca/models/res_partner.py b/edi_gs1_oca/models/res_partner.py new file mode 100644 index 000000000..6bf612db7 --- /dev/null +++ b/edi_gs1_oca/models/res_partner.py @@ -0,0 +1,12 @@ +# Copyright 2020 ACSONE SA/NV () +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + # TODO: move to another module? Or, is there another module providing this? + gln_code = fields.Char() + is_lsp = fields.Boolean(string="Is Logistic Services Provider (LSP)") diff --git a/edi_gs1_oca/models/uom_uom.py b/edi_gs1_oca/models/uom_uom.py new file mode 100644 index 000000000..acc375296 --- /dev/null +++ b/edi_gs1_oca/models/uom_uom.py @@ -0,0 +1,70 @@ +# Copyright 2020 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class UomUom(models.Model): + _inherit = "uom.uom" + + gs1_code = fields.Char( + string="GS1 code", help="Code used to export with GS1 format" + ) + + @api.model + def _gs1_mapping_code(self): + """ + Get a dict with the mapping between the UoM code (from Odoo) and the GS1 code. + This mapping table is used into the init_hook of the module to automatically + init existing UoM. + key should be always in lower case! + Codes come from this url: + https://resources.gs1us.org/GS1-US-Data-Hub-Help-Center/ArtMID/3451/ + ArticleID/116/Unit-of-Measure-Codes + :return: dict + """ + map_code = { + "kg": "KGM", + "day": "DAY", + "days": "DAY", + "box": "BX", + "boxes": "BX", + "dozen": "DZN", + "dozens": "DZN", + "hours": "HUR", + "hour": "HUR", + "liters": "LTR", + "liter": "LTR", + "cm": "CMT", + "fl oz": "OZI", + "foot(ft)": "FOT", + "foot": "FOT", + "g": "GRM", + "gal": "GLI", + "gals": "GLI", + "inches": "INH", + "km": "KMT", + "m": "MTR", + } + return map_code + + @api.model + def _execute_gs1_map_code(self, overwrite_existing=False): + """ + Execute (or update) the gs1 mapping based on UoM name (lower case). + By default, it doesn't update UoM with a value into gs1_code. + But it can be forced by setting the overwrite_existing parameter to True. + :param overwrite_existing: bool + :return: bool + """ + domain = [("gs1_code", "=", False)] + if overwrite_existing: + domain = [] + uoms = self.env["uom.uom"].search(domain) + map_code = uoms._gs1_mapping_code() + for uom in uoms: + # Key always in lower case so the name too! + name = uom.name.lower() + gs1_code = map_code.get(name) + if gs1_code and uom.gs1_code != gs1_code: + uom.write({"gs1_code": gs1_code}) + return True diff --git a/edi_gs1_oca/pyproject.toml b/edi_gs1_oca/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/edi_gs1_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_gs1_oca/readme/CONTRIBUTORS.md b/edi_gs1_oca/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..5792655b0 --- /dev/null +++ b/edi_gs1_oca/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Simone Orsi \<\> diff --git a/edi_gs1_oca/readme/DESCRIPTION.md b/edi_gs1_oca/readme/DESCRIPTION.md new file mode 100644 index 000000000..7f5b2ba48 --- /dev/null +++ b/edi_gs1_oca/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +This is the base module for the implementation of the [GS1 -The Global +Language of Business](http://gs1.org/) standard. + +This module contains methods to generate and parse XML files compliant +with this standard. + +Beside basic machinery it provides common components to generate common +GS1 schema elements. Namely: + +> - StandardBusinessDocumentHeader diff --git a/edi_gs1_oca/security/gs1_backend_acl.xml b/edi_gs1_oca/security/gs1_backend_acl.xml new file mode 100644 index 000000000..eda64df05 --- /dev/null +++ b/edi_gs1_oca/security/gs1_backend_acl.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/edi_gs1_oca/static/description/icon.png b/edi_gs1_oca/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/edi_gs1_oca/static/description/icon.png differ diff --git a/edi_gs1_oca/static/description/index.html b/edi_gs1_oca/static/description/index.html new file mode 100644 index 000000000..f3d540eee --- /dev/null +++ b/edi_gs1_oca/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Base GS1 EDI

+ +

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

+

This is the base module for the implementation of the GS1 -The Global +Language of Business standard.

+

This module contains methods to generate and parse XML files compliant +with this standard.

+

Beside basic machinery it provides common components to generate common +GS1 schema elements. Namely:

+
+
    +
  • StandardBusinessDocumentHeader
  • +
+
+

Table of contents

+ +
+

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

+
    +
  • ACSONE
  • +
+
+
+

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-framework project on GitHub.

+

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

+
+
+
+
+ + diff --git a/edi_gs1_oca/static/schemas/gs1/ecom/ApplicationReceiptAcknowledgement.xsd b/edi_gs1_oca/static/schemas/gs1/ecom/ApplicationReceiptAcknowledgement.xsd new file mode 100644 index 000000000..22c9524b7 --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/ecom/ApplicationReceiptAcknowledgement.xsd @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingCommon.xsd b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingCommon.xsd new file mode 100644 index 000000000..3395efe4d --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingCommon.xsd @@ -0,0 +1,280 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingInboundInstruction.xsd b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingInboundInstruction.xsd new file mode 100644 index 000000000..942d4e657 --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingInboundInstruction.xsd @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingInboundNotification.xsd b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingInboundNotification.xsd new file mode 100644 index 000000000..c4231c980 --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingInboundNotification.xsd @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingOutboundInstruction.xsd b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingOutboundInstruction.xsd new file mode 100644 index 000000000..c43381530 --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingOutboundInstruction.xsd @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingOutboundNotification.xsd b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingOutboundNotification.xsd new file mode 100644 index 000000000..61ab3416b --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/ecom/WarehousingOutboundNotification.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/gs1/ecom/eComCommon.xsd b/edi_gs1_oca/static/schemas/gs1/ecom/eComCommon.xsd new file mode 100644 index 000000000..ab3bfc7b4 --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/ecom/eComCommon.xsd @@ -0,0 +1,1858 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/gs1/shared/SharedCommon.xsd b/edi_gs1_oca/static/schemas/gs1/shared/SharedCommon.xsd new file mode 100644 index 000000000..d51b853df --- /dev/null +++ b/edi_gs1_oca/static/schemas/gs1/shared/SharedCommon.xsd @@ -0,0 +1,1506 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/sbdh/BasicTypes.xsd b/edi_gs1_oca/static/schemas/sbdh/BasicTypes.xsd new file mode 100644 index 000000000..3718acfe5 --- /dev/null +++ b/edi_gs1_oca/static/schemas/sbdh/BasicTypes.xsd @@ -0,0 +1,20 @@ + + + + + + The MIME type as defined by IANA. Please refer to + http://www.iana.org/assignments/media-types/ for a list of types. + + + + + + + + ISO 639-2; 1998 representation of Language name. Refer to http://www.loc.gov/standards/iso639-2/iso639jac.html to get the latest version of the standard. + + + + + diff --git a/edi_gs1_oca/static/schemas/sbdh/BusinessScope.xsd b/edi_gs1_oca/static/schemas/sbdh/BusinessScope.xsd new file mode 100644 index 000000000..19ddd7ce0 --- /dev/null +++ b/edi_gs1_oca/static/schemas/sbdh/BusinessScope.xsd @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/sbdh/DocumentIdentification.xsd b/edi_gs1_oca/static/schemas/sbdh/DocumentIdentification.xsd new file mode 100644 index 000000000..5264a2e27 --- /dev/null +++ b/edi_gs1_oca/static/schemas/sbdh/DocumentIdentification.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/sbdh/Manifest.xsd b/edi_gs1_oca/static/schemas/sbdh/Manifest.xsd new file mode 100644 index 000000000..59bed11f1 --- /dev/null +++ b/edi_gs1_oca/static/schemas/sbdh/Manifest.xsd @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/sbdh/Partner.xsd b/edi_gs1_oca/static/schemas/sbdh/Partner.xsd new file mode 100644 index 000000000..ff4258da6 --- /dev/null +++ b/edi_gs1_oca/static/schemas/sbdh/Partner.xsd @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/static/schemas/sbdh/StandardBusinessDocumentHeader.xsd b/edi_gs1_oca/static/schemas/sbdh/StandardBusinessDocumentHeader.xsd new file mode 100644 index 000000000..b97fb45f4 --- /dev/null +++ b/edi_gs1_oca/static/schemas/sbdh/StandardBusinessDocumentHeader.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/tests/__init__.py b/edi_gs1_oca/tests/__init__.py new file mode 100644 index 000000000..321fab57d --- /dev/null +++ b/edi_gs1_oca/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_business_header + +# from . import test_application_receipt_ack diff --git a/edi_gs1_oca/tests/common.py b/edi_gs1_oca/tests/common.py new file mode 100644 index 000000000..7c0b2bae5 --- /dev/null +++ b/edi_gs1_oca/tests/common.py @@ -0,0 +1,57 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os + +import xmlunittest + +from odoo.tests.common import TransactionCase, tagged + + +@tagged("-at_install", "post_install") +class BaseTestCase(TransactionCase, xmlunittest.XmlTestMixin): + _schema_path = "edi_gs1_oca:static/schemas/sbdh/StandardBusinessDocumentHeader.xsd" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.backend = cls._get_backend() + # Logistic Services Provider (LSP) + cls.lsp_partner = cls.env["res.partner"].create( + { + "name": "Test LSP", + "email": "lsp@example.com", + "phone": "+1-212-555-0001", + } + ) + # Logistic Services Client (LSC) + cls.lsc_partner = cls.env.ref("base.main_partner") + cls.lsc_partner.write({"email": "lsc@example.com", "phone": "+1-212-555-0002"}) + cls.backend.lsp_partner_id = cls.lsp_partner + cls.backend.lsc_partner_id = cls.lsc_partner + + # set fake GLN codes + cls.lsp_partner.gln_code = "1".zfill(13) + cls.lsc_partner.gln_code = "2".zfill(13) + # We have to trigger this gs1_code update manually in case of a submodule + # update them. + cls.env["uom.uom"]._execute_gs1_map_code() + + @classmethod + def _get_backend(cls): + return cls.env.ref("edi_gs1_oca.edi_backend_gs1_default") + + def flatten(self, txt): + return "".join([x.strip() for x in txt.splitlines()]) + + def read_test_file(self, filename): + path = os.path.join(os.path.dirname(__file__), "examples", filename) + with open(path) as thefile: + return thefile.read() + + def _validate_xml(self, content, schema_path=None): + return self.env["edi.gs1.xml"].validate( + schema_path or self._schema_path, content + ) diff --git a/edi_gs1_oca/tests/examples/ApplicationReceiptAcknowledgement.example1.xml b/edi_gs1_oca/tests/examples/ApplicationReceiptAcknowledgement.example1.xml new file mode 100644 index 000000000..8cd39ca3f --- /dev/null +++ b/edi_gs1_oca/tests/examples/ApplicationReceiptAcknowledgement.example1.xml @@ -0,0 +1,65 @@ + + + + 1.0 + + + + John Doe + John_Doe@purchasing.XYZretailer.com + +1-212-555-1213 + +1-212-555-2122 + Buyer + + + + + + Mary Smith + Mary_Smith@widgets.com + +1-312-555-1214 + +1-312-555-2125 + Seller + + + + GS1 + 3.4 + 100002 + + false + 2006-01-10T12:00:01.000-05:00 + + + + 2011-03-11T11:43:00.000-05:00 + ORIGINAL + + RA0009 + + 4098765000010 + + + + ERROR + 2011-03-11T11:00:00.000-05:00 + 35 + + PO3352 + + 5412345000013 + + + + INCOMPLETE_MESSAGE + + + + diff --git a/edi_gs1_oca/tests/test_business_header.py b/edi_gs1_oca/tests/test_business_header.py new file mode 100644 index 000000000..7bfcfc54b --- /dev/null +++ b/edi_gs1_oca/tests/test_business_header.py @@ -0,0 +1,101 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from .common import BaseTestCase + +BH_NS = "http://www.unece.org/cefact/namespaces/StandardBusinessDocumentHeader" + + +class BusinessHeaderTestCase(BaseTestCase): + _schema_path = "edi_gs1_oca:static/schemas/sbdh/StandardBusinessDocumentHeader.xsd" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_records() + + @classmethod + def _setup_records(cls): + cls.output_handler = cls.env["edi.gs1.output.mixin"] + vals = { + "backend_id": cls.backend.id, + "backend_type_id": cls.backend.backend_type_id.id, + "name": "Template output 1", + "direction": "output", + "code": "test_type_out1", + "exchange_file_ext": "txt", + "exchange_filename_pattern": "{record.ref}-{type.code}-{dt}", + # Output types require a send handler (edi_core_oca constraint). + "send_model_id": cls.env.ref("edi_core_oca.model_edi_oca_handler_noop").id, + } + cls.exc_type = cls.env["edi.exchange.type"].create(vals) + # Does not really matter which record we bind the exchange to + cls.related_record = cls.env["res.partner"].create({"name": "Test Record"}) + vals = { + "model": cls.related_record._name, + "res_id": cls.related_record.id, + "type_id": cls.exc_type.id, + } + cls.exc_record = cls.backend.create_record("test_type_out1", vals) + + def test_template_render_values(self): + values = self.output_handler._get_render_values(self.exc_record) + expected = { + "backend": self.backend, + "exchange_record": self.exc_record, + "instance_identifier": self.exc_record.identifier, + "record": self.related_record, + } + for k, v in expected.items(): + self.assertEqual(values[k], v) + + @freeze_time("2020-07-08 07:30:00") + def test_xml(self): + output = self.output_handler._render_edi_template( + self.exc_record, + "edi_gs1_oca.edi_exchange_business_header", + sender=self.lsc_partner, + receiver=self.lsp_partner, + ) + expected = """ + + 1.0 + + + + {sender.name} + {sender.email} + {sender.phone} + Buyer + + + + + + {receiver.name} + {receiver.email} + {receiver.phone} + Seller + + + + GS1 + 3.4 + {identifier} + + false + {date} + + + """.format( + ns=BH_NS, + sender=self.lsc_partner, + receiver=self.lsp_partner, + identifier=self.exc_record.identifier, + date="2020-07-08T07:30:00", + ) + self.assertXmlEquivalentOutputs(self.flatten(output), self.flatten(expected)) + self.assertEqual(self._validate_xml(output), None) diff --git a/edi_gs1_oca/utils.py b/edi_gs1_oca/utils.py new file mode 100644 index 000000000..e22e20014 --- /dev/null +++ b/edi_gs1_oca/utils.py @@ -0,0 +1,39 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from lxml import etree + + +def xml_purge_nswrapper(xml_content): + """Purge `nswrapper` elements. + + QWeb template does not allow parsing namespaced elements + without declaring namespaces on the root element. + + Hence, by default you cannot define smaller re-usable templates + if they have namespaced elements. + + The trick is to wrap your reusable template with `nswrapper` element + which holds the namespace for that particular sub template. + For instance: + + + + + + Then this method is going to purge these unwanted elements from the result. + """ + if not (xml_content and xml_content.strip()): + return xml_content + root = etree.XML(xml_content) + # deeper elements come after, keep the root element at the end (if any) + for nswrapper in reversed(root.xpath("//nswrapper")): + parent = nswrapper.getparent() + if parent is None: + return "".join( + etree.tostring(el, encoding="unicode") for el in nswrapper.getchildren() + ) + parent.extend(nswrapper.getchildren()) + parent.remove(nswrapper) + return etree.tostring(root, encoding="unicode") diff --git a/edi_gs1_oca/views/edi_backend.xml b/edi_gs1_oca/views/edi_backend.xml new file mode 100644 index 000000000..528ceb46a --- /dev/null +++ b/edi_gs1_oca/views/edi_backend.xml @@ -0,0 +1,23 @@ + + + + + edi.backend.form (in edi_gs1_oca) + edi.backend + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/views/res_partner.xml b/edi_gs1_oca/views/res_partner.xml new file mode 100644 index 000000000..a0656ba4a --- /dev/null +++ b/edi_gs1_oca/views/res_partner.xml @@ -0,0 +1,24 @@ + + + + + res.partner.form (in edi_gs1_oca) + res.partner + + + + + + + + + + + + + + + + + diff --git a/edi_gs1_oca/views/uom_uom.xml b/edi_gs1_oca/views/uom_uom.xml new file mode 100644 index 000000000..460b4fd1b --- /dev/null +++ b/edi_gs1_oca/views/uom_uom.xml @@ -0,0 +1,16 @@ + + + + + uom.uom.form (in edi_gs1_oca) + uom.uom + + + + + + + + + diff --git a/edi_gs1_oca/xmler.py b/edi_gs1_oca/xmler.py new file mode 100644 index 000000000..24888ec63 --- /dev/null +++ b/edi_gs1_oca/xmler.py @@ -0,0 +1,134 @@ +""" +Converts a Python dictionary to a valid XML string + +https://github.com/watzon/xmler Slightly modified and adapted for py3. + +TODO: project seems dead but it's very small and shall be easy to maintain. +Decide if we want to keep it here or contribute back. +""" + +from xml.dom import minidom +from xml.etree.ElementTree import Element, tostring + +__version__ = "0.2.0" +version = __version__ + + +def dict2xml(input_dict, encoding="utf-8", pretty=False): + """Converts a python dictionary into a valid XML string + + Args: + - encoding specifies the encoding to be included in the encoding + segment. If set to False no encoding segment will be displayed. + - customRoot defines the tag to wrap the returned output. Can be + a string, dictionary, or False if no custom root is to be used. + + Returns: + A XML formatted string representing the dictionary. + + Examples: + ``` + dic = { + "Envelope": { + "@ns": "soapenv", + "@attrs": { + "xmlns:soapenv": "http://schemas.xmlsoap.org/soap/envelope/", + "xmlns:urn": "urn:partner.soap.sforce.com" + }, + "Header": { + "@ns": "soapenv", + "SessionHeader": { + "@ns": "urn", + "sessionId": { + "@ns": "urn", + "@value": "00D36000000b28L!ARsAQMtHo4XD71VYRxoz" + } + } + }, + "Body": { + "@ns": "soapenv", + "query": { + "@ns": "urn", + "queryString": { + "@ns": "urn", + "@value": "SELECT Id, Name FROM Account LIMIT 2" + } + } + } + } + } + + xml = xml2dict(dict, customRoot=False) + print(xml) + ``` + + output: + ``` + + + + + {0} + + + + ``` + """ + + xml_string = tostring(parse(input_dict, pretty=pretty), encoding=encoding) + + if pretty: + xml_pretty_string = minidom.parseString(xml_string) + return xml_pretty_string.toprettyxml().decode(encoding) + else: + return xml_string.decode(encoding) + + +def parse(input_dict, parent=None, pretty=False): + parent = parent or {} + for key, value in input_dict.items(): + if isinstance(value, (float, int)): + # Enfoce strings here + value = str(value) + + parent["name"] = key + parent["value"] = value + + if isinstance(value, dict): + if "@ns" in value: + parent["namespace"] = value.pop("@ns", None) + + if "@attrs" in value: + parent["attributes"] = value.pop("@attrs", None) + + if "@name" in value: + parent["name"] = value.pop("@name", None) + + if "@value" in value: + val = value["@value"] + if isinstance(val, (float, int)): + # Enfoce strings here + val = str(val) + parent["value"] = value = val + + if "namespace" in parent: + parent["name"] = "{}:{}".format(parent["namespace"], parent["name"]) + + if "attributes" in parent: + element = Element(parent["name"], parent["attributes"]) + else: + element = Element(parent["name"]) + + if isinstance(parent["value"], dict): + for child_key, child_value in parent["value"].items(): + element.append(parse({child_key: child_value}, parent={})) + + elif isinstance(parent["value"], (list, set, tuple)): + for child in parent["value"]: + element.append(parse(child, parent={})) + + else: + element.text = parent["value"] + + return element diff --git a/edi_gs1_stock_oca/README.rst b/edi_gs1_stock_oca/README.rst new file mode 100644 index 000000000..4f01425c8 --- /dev/null +++ b/edi_gs1_stock_oca/README.rst @@ -0,0 +1,88 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +GS1 EDI for stock +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:58f873403235289f5c44a5f029c949b43b56fe09a12486f8c6b7763d06acc5f0 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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--framework-lightgray.png?logo=github + :target: https://github.com/OCA/edi-framework/tree/19.0/edi_gs1_stock_oca + :alt: OCA/edi-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-framework-19-0/edi-framework-19-0-edi_gs1_stock_oca + :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-framework&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +`GS1 -The Global Language of Business `__ +implementation for stock. + +Provides support to generate or process the following messages: + + - Warehouse Inbound Instruction [WIP] + - Warehouse Inbound Notification [TODO] + - Warehouse Outbound Instruction [TODO] + - Warehouse Outbound Notification [TODO] + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* ACSONE + +Contributors +------------ + +- Simone Orsi + +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-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_gs1_stock_oca/__init__.py b/edi_gs1_stock_oca/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/edi_gs1_stock_oca/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/edi_gs1_stock_oca/__manifest__.py b/edi_gs1_stock_oca/__manifest__.py new file mode 100644 index 000000000..a37bfb4a3 --- /dev/null +++ b/edi_gs1_stock_oca/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "GS1 EDI for stock", + "summary": """ + Base module for GS1 standard EDI exchange related to stock. + """, + "version": "19.0.1.0.0", + "development_status": "Beta", + "license": "AGPL-3", + "author": "ACSONE,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/edi-framework", + "depends": [ + "edi_gs1_oca", + "stock", + "purchase_stock", + # provides `carrier_id` and `weight` on stock.picking (Odoo 19 moved + # them out of `delivery` into `stock_delivery`) + "stock_delivery", + ], + "external_dependencies": {"python": ["xmlschema", "xmlunittest"]}, + "data": [ + "views/res_config_settings.xml", + "views/stock_picking_view.xml", + "data/edi_exchange_type.xml", + "data/inbound_instruction_qweb_template.xml", + "data/outbound_instruction_qweb_template.xml", + ], +} diff --git a/edi_gs1_stock_oca/data/edi_exchange_type.xml b/edi_gs1_stock_oca/data/edi_exchange_type.xml new file mode 100644 index 000000000..cda6c4592 --- /dev/null +++ b/edi_gs1_stock_oca/data/edi_exchange_type.xml @@ -0,0 +1,92 @@ + + + + + GS1 Inbound Instruction + + warehousingInboundInstruction + output + {record_name}-{type.code}-{dt} + xml + + + + + +gs1: + schema_path: edi_gs1_oca:static/schemas/gs1/ecom/WarehousingInboundInstruction.xsd + + + + + + GS1 Inbound Notification + warehousingInboundNotification + input + {record_name}-{type.code}-{dt} + xml + + + + +gs1: + schema_path: edi_gs1_oca:static/schemas/gs1/ecom/WarehousingInboundNotification.xsd + + + + + + GS1 Outbound Instruction + warehousingOutboundInstruction + output + {record_name}-{type.code}-{dt} + xml + + + + + +gs1: + schema_path: edi_gs1_oca:static/schemas/gs1/ecom/WarehousingOutboundInstruction.xsd + + + + + GS1 Outbound Notification + warehousingOutboundNotificationMessage + input + {record_name}-{type.code}-{dt} + xml + + + + +gs1: + schema_path: edi_gs1_oca:static/schemas/gs1/ecom/WarehousingOutboundNotification.xsd + + + diff --git a/edi_gs1_stock_oca/data/inbound_instruction_qweb_template.xml b/edi_gs1_stock_oca/data/inbound_instruction_qweb_template.xml new file mode 100644 index 000000000..9c3fb8abb --- /dev/null +++ b/edi_gs1_stock_oca/data/inbound_instruction_qweb_template.xml @@ -0,0 +1,118 @@ + + + + diff --git a/edi_gs1_stock_oca/data/outbound_instruction_qweb_template.xml b/edi_gs1_stock_oca/data/outbound_instruction_qweb_template.xml new file mode 100644 index 000000000..daafda53f --- /dev/null +++ b/edi_gs1_stock_oca/data/outbound_instruction_qweb_template.xml @@ -0,0 +1,195 @@ + + + + diff --git a/edi_gs1_stock_oca/models/__init__.py b/edi_gs1_stock_oca/models/__init__.py new file mode 100644 index 000000000..a805e7dce --- /dev/null +++ b/edi_gs1_stock_oca/models/__init__.py @@ -0,0 +1,6 @@ +from . import edi_gs1_shipment_output +from . import edi_gs1_shipment_input +from . import edi_gs1_handler +from . import res_company +from . import res_config_settings +from . import stock_picking diff --git a/edi_gs1_stock_oca/models/edi_gs1_handler.py b/edi_gs1_stock_oca/models/edi_gs1_handler.py new file mode 100644 index 000000000..fe902271d --- /dev/null +++ b/edi_gs1_stock_oca/models/edi_gs1_handler.py @@ -0,0 +1,293 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from markupsafe import Markup + +from odoo import models + + +class GS1GenerateInstructionMixin(models.AbstractModel): + """Common ``generate`` handler for GS1 warehousing instructions. + + Replaces the old ``edi.exchange.template.output`` records: the work + context (formerly built in the ``code_snippet``) and the QWeb + rendering (formerly driven by the template ``code``) are now produced + here and the result is returned to the EDI backend as the output. + """ + + _name = "edi.gs1.generate.instruction.mixin" + _inherit = ["edi.oca.handler.generate", "edi.gs1.output.shipment.mixin"] + _description = "GS1 generate warehousing instruction mixin" + + # Full xmlid of the QWeb template to render, set on concrete handlers. + _template_ref = None + + def generate(self, exchange_record): + work_ctx = self._get_work_ctx(exchange_record) + work_ctx["info"] = self.generate_info(exchange_record, **work_ctx) + # Some optional template branches rely on a falsy `TODO` flag. + work_ctx.setdefault("TODO", False) + # Pre-render the business header and inject it (via ``t-out``) at the + # right position. We cannot ``t-call`` it inline because the + # ``nswrapper`` purge moves its content to the end of the document, + # which breaks the XSD element ordering (the header must come first). + work_ctx["business_header"] = Markup( + self._render_edi_template( + exchange_record, + "edi_gs1_oca.edi_exchange_business_header", + **self._business_header_params(work_ctx), + ) + ) + return self._render_edi_template( + exchange_record, self._template_ref, **work_ctx + ) + + def _get_work_ctx(self, exchange_record): + """Build the partners context used by the QWeb template.""" + raise NotImplementedError() + + def _business_header_params(self, work_ctx): + """Values passed to the GS1 business header template.""" + return { + "sender": work_ctx["sender"], + "receiver": work_ctx["receiver"], + } + + def _get_shipper_partner(self, exchange_record): + """Return the shipper (carrier) partner. + + The original module read ``record.carrier_id.partner_id``, but + ``carrier_id`` is provided by ``stock_delivery`` (not a hard + dependency) and the core ``delivery.carrier`` model has no + ``partner_id`` field. We keep that source when available (e.g. + ``stock_delivery`` installed and ``delivery.carrier`` extended with a + partner) and fall back to the backend LSP partner otherwise. + """ + record = exchange_record.record + carrier = record.carrier_id if "carrier_id" in record._fields else None + if carrier and "partner_id" in carrier._fields and carrier.partner_id: + return carrier.partner_id + return exchange_record.backend_id.lsp_partner_id + + +class GS1GenerateOutboundInstruction(models.AbstractModel): + """Generate data for Outbound Instruction. + + The Warehousing Outbound Instruction message enables a Logistic + Services Client (LSC) to inform his Logistic Services Provider (LSP) + that goods will be shipped. + """ + + _name = "edi.gs1.generate.outbound.instruction" + _inherit = "edi.gs1.generate.instruction.mixin" + _description = "GS1 generate Warehousing Outbound Instruction" + + _template_ref = "edi_gs1_stock_oca.edi_exchange_outbound_instruction" + + def _business_header_params(self, work_ctx): + return { + "sender": work_ctx["sender"], + "receiver": work_ctx["ls_seller"], + "doc_type": "Warehousing Outbound Instruction", + } + + def _get_work_ctx(self, exchange_record): + backend = exchange_record.backend_id + record = exchange_record.record + sender = backend.lsc_partner_id + return { + "sender": sender, + "receiver": record.partner_id, + "ls_seller": backend.lsp_partner_id, + "ls_buyer": sender, + # FIXME: this is probably wrong or not always true + "buyer": record.partner_id, + # TODO: Pick the SO partner customer. + "seller": backend.lsc_partner_id, + # TODO: get a default carrier somehow? + "shipper": self._get_shipper_partner(exchange_record), + "shipto": record.partner_id, + } + + def _generate_info(self, exchange_record, **kw): + data = { + "creationDateTime": self._utc_now(), + # status code can stay always as it is if we don't send around copies + "documentStatusCode": "ORIGINAL", + "documentActionCode": self._document_action_code(), + # fmt: off + "warehousingOutboundInstructionShipment": self._shipment_info( + exchange_record, **kw + ), + # fmt: on + } + return {"warehousingOutboundInstruction": data} + + def _shipment_info_elements(self): + res = super()._shipment_info_elements() + res.update( + { + "warehousingDespatchTypeCode": self._despatch_type_code, + "plannedDespatch": self._planned_despatch, + } + ) + return res + + def _despatch_type_code(self, exchange_record, **kw): + """TODO get it from picking or mapping configuration or leave + + CROSS-DOCKED_SHIPMENT + Cross-docked shipment + One on one cross-dock of an incoming cross-dock shipment. + WAREHOUSE_CROSS-DOCK_COMBINATION + Warehouse cross-dock combination + combination of goods picked from stock + and goods taken from cross-docked receipts. + WAREHOUSE_SHIPMENT + Warehouse shipment. + All goods are picked from stock. + """ + return "WAREHOUSE_SHIPMENT" + + def _planned_despatch(self, exchange_record, **kw): + # TODO: better info from? + values = {} + record = exchange_record.record + if record.scheduled_date: + values.update( + { + "logisticEventPeriod": { + "beginDate": self.date_to_string(record.scheduled_date), + }, + } + ) + return values + + +class GS1GenerateInboundInstruction(models.AbstractModel): + """Generate data for Inbound Instruction. + + The Warehousing Inbound Instruction message enables a Logistic + Services Client (LSC) to inform his Logistic Services Provider (LSP) + that goods will be arriving. + """ + + _name = "edi.gs1.generate.inbound.instruction" + _inherit = "edi.gs1.generate.instruction.mixin" + _description = "GS1 generate Warehousing Inbound Instruction" + + _template_ref = "edi_gs1_stock_oca.edi_exchange_inbound_instruction" + + def _get_work_ctx(self, exchange_record): + backend = exchange_record.backend_id + record = exchange_record.record + sender = backend.lsc_partner_id + receiver = backend.lsp_partner_id + return { + "sender": sender, + "receiver": receiver, + "ls_seller": receiver, + "ls_buyer": sender, + # FIXME: this is probably wrong or not always true + "buyer": sender, + "seller": record.purchase_id.partner_id, + "shipper": self._get_shipper_partner(exchange_record), + } + + def _generate_info(self, exchange_record, **kw): + data = { + "creationDateTime": self._utc_now(), + # status code can stay always as it is if we don't send around copies + "documentStatusCode": "ORIGINAL", + "documentActionCode": self._document_action_code(), + # fmt: off + "warehousingInboundInstructionShipment": self._shipment_info( + exchange_record, **kw + ), + # fmt: on + } + return {"warehousingInboundInstruction": data} + + def _shipment_info_elements(self): + res = super()._shipment_info_elements() + res.update( + { + "warehousingReceiptTypeCode": self._receipt_type_code, + "plannedReceipt": self._planned_receipt, + } + ) + return res + + def _receipt_type_code(self, exchange_record, **kw): + """TODO + + # cross-dock receipt. + # The instructed receipt is intended to be cross-docked. + "CROSS-DOCK_RECEIPT", + # priority receipt. + # The instructed receipt needs to be processed with high priority. + "PRIORITY_RECEIPT", + # regular receipt Normal receipt, no special actions required. + "REGULAR_RECEIPT", + # repair receipt. + # The instructed receipt relates to goods that were under repair. + "REPAIR_RECEIPT", + # return + # The instructed receipt is a return, for example a customer return or a recall. + "RETURNS", + """ + return "REGULAR_RECEIPT" + + def _planned_receipt(self, exchange_record, **kw): + # TODO: better info from? + return { + "logisticEventDateTime": { + "date": self.date_to_string(exchange_record.record.scheduled_date) + or "", + }, + } + + +class GS1ProcessOutboundNotification(models.AbstractModel): + """Process data for Outbound Notification. + + The Warehousing Outbound Notification message enables a Logistic + Services Provider (LSP) to inform his Logistic Services Client (LSC) + on the status of goods received on behalf of the client. + """ + + _name = "edi.gs1.process.outbound.notification" + _inherit = ["edi.oca.handler.process", "edi.gs1.input.shipment.mixin"] + _description = "GS1 process Warehousing Outbound Notification" + + def process(self, exchange_record): + data = self._parse(exchange_record) + self._process_data(data, exchange_record) + return self.env._("warehousingOutboundNotification processed") + + def _process_data(self, data, exchange_record): + # TODO + raise NotImplementedError() + + +class GS1ProcessInboundNotification(models.AbstractModel): + """Process data for Inbound Notification. + + The Warehousing Inbound Notification message enables a Logistic + Services Provider (LSP) to inform his Logistic Services Client (LSC) + on the status of goods received on behalf of the client. + """ + + _name = "edi.gs1.process.inbound.notification" + _inherit = ["edi.oca.handler.process", "edi.gs1.input.shipment.mixin"] + _description = "GS1 process Warehousing Inbound Notification" + + def process(self, exchange_record): + data = self._parse(exchange_record) + self._process_data(data, exchange_record) + return self.env._("warehousingInboundNotification processed") + + def _process_data(self, data, exchange_record): + # TODO + raise NotImplementedError() diff --git a/edi_gs1_stock_oca/models/edi_gs1_shipment_input.py b/edi_gs1_stock_oca/models/edi_gs1_shipment_input.py new file mode 100644 index 000000000..be8d58e20 --- /dev/null +++ b/edi_gs1_stock_oca/models/edi_gs1_shipment_input.py @@ -0,0 +1,22 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class GS1InputShipmentMessageMixin(models.AbstractModel): + """Common GS1 input shipment mixin. + + Replaces the old ``edi.gs1.input.shipment.mixin`` component. + Concrete processors inherit this mixin together with + ``edi.oca.handler.process``. + """ + + _name = "edi.gs1.input.shipment.mixin" + _inherit = "edi.gs1.input.mixin" + _description = "GS1 EDI input shipment mixin" + + def _process_data(self, data, exchange_record): + # TODO + pass diff --git a/edi_gs1_stock_oca/models/edi_gs1_shipment_output.py b/edi_gs1_stock_oca/models/edi_gs1_shipment_output.py new file mode 100644 index 000000000..03cad799e --- /dev/null +++ b/edi_gs1_stock_oca/models/edi_gs1_shipment_output.py @@ -0,0 +1,195 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import exceptions, models +from odoo.tools import DotDict + + +class GS1OutputShipmentMessageMixin(models.AbstractModel): + """Common GS1 output shipment mixin. + + Replaces the old ``edi.gs1.output.shipment.mixin`` component. + Concrete generators inherit this mixin together with + ``edi.oca.handler.generate``. All the info builders receive the + ``exchange_record`` and the work context (``shipper``, ``ls_buyer``, + ``ls_seller``, ...) as keyword arguments, since the component + ``self.work`` context does not exist anymore. + """ + + _name = "edi.gs1.output.shipment.mixin" + _inherit = "edi.gs1.output.mixin" + _description = "GS1 EDI output shipment mixin" + + def _shipment_info(self, exchange_record, **kw): + data = { + "shipmentIdentification": self._shipment_identification( + exchange_record, **kw + ) + } + for key, handler in self._shipment_info_elements().items(): + value = handler(exchange_record, **kw) + # Return empty dict or None in your handler to skip an element + if value: + data[key] = value + return data + + def _shipment_identification(self, exchange_record, **kw): + return { + "additionalShipmentIdentification": { + "attrs": { + # fmt: off + "additionalShipmentIdentificationTypeCode": "GOODS_RECEIVER_ASSIGNED" # noqa: E501 + # fmt: on + }, + "value": exchange_record.record.name, + } + } + + def _shipment_info_elements(self): + return { + "shipper": self._shipper, + "packageTotal": self._package_total, + "_shipment_items": self._shipment_items, + } + + def _get_shipper_record(self, exchange_record, **kw): + # We should get the carrier here + return kw.get("shipper") + + def _shipper(self, exchange_record, **kw): + """The carrier of the shipment.""" + record = self._get_shipper_record(exchange_record, **kw) + if not record.gln_code and not record.ref: + raise exceptions.ValidationError( + self.env._( + "Either `gln_code` or `ref` is required for shipper: %(name)s", + name=record.name, + ) + ) + # `gln` is required in the schema. + # Depending on your LSP having a fake one and relying on + # `additionalPartyIdentification` can be enough. + data = {"gln_code": "".zfill(13)} + if record.gln_code: + data["gln_code"] = record.gln_code + if record.ref: + data["additionalPartyIdentification"] = { + "attrs": { + # fmt: off + "additionalPartyIdentificationTypeCode": "BUYER_ASSIGNED_IDENTIFIER_FOR_A_PARTY" # noqa: E501 + # fmt: on + }, + "value": record.ref, + } + return data + + def _package_total(self, exchange_record, **kw): + record = exchange_record.record + return [ + DotDict( + { + # TODO: would be nice to have mapping based on product packaging + # but as in some case you simply use the same package types + # for all the shipments, then is up to integrator to provide + # proper values by overriding this method. + # https://www.gs1.se/en/our-standards/Technical-documentation/ + # code-lists/t0137-packaging-type-code/ + # TODO: get generic numbers from picking. + "packageTypeCode": "AF", + "totalPackageQuantity": "2", + "totalGrossWeight": { + "value": record.weight, + "attrs": {"measurementUnitCode": "KGM"}, + }, + } + ), + ] + + def _shipment_items(self, exchange_record, **kw): + res = [] + for i, item in enumerate( + self._get_shipment_items(exchange_record, **kw), start=1 + ): + res.append(self._shipment_item(item, i)) + return res + + def _get_shipment_items(self, exchange_record, **kw): + # Use stock moves (the planned demand): on Odoo 19 ``stock.move.line`` + # no longer carries ``product_uom_qty`` and detailed move lines might + # not be created yet for a planned shipment. + return exchange_record.record.move_ids + + def _shipment_item(self, item, i=1): + qty = item.product_uom_qty + # TODO: get it from line uom + uom_code = "KGM" + # Watch out: order is important for XSD validation! + inventory_fee_tax_status = [ + DotDict(v) for v in self._inventory_duty_fee_tax_status(item) + ] + data = DotDict( + { + "lineItemNumber": i, + "transactionalTradeItem": self._shipment_item_trade_item(item), + "note": self._shipment_item_note(item), + "inventoryDutyFeeTaxStatus": inventory_fee_tax_status, + } + ) + avp_list = self._shipment_item_avp_list(item) + if avp_list: + data["avpList"] = avp_list + # NOTE: `_plannedQty` does not match the final key + # which vary depending on the message + # (eg: plannedReceiptQuantity, plannedDespatchQuantity) + # You should handle it properly in your template. + data["_plannedQty"] = { + "value": qty, + "attrs": {"measurementUnitCode": uom_code}, + } + return data + + def _inventory_duty_fee_tax_status(self, item): + """ + + :param item: + :return: list of dict + """ + return [] + + def _shipment_item_note(self, item): + return {} + + def _shipment_item_trade_item(self, item): + # GTIN is required here. + # We assume you set it on product's default code. + # NOTE: GTIN must be 14 chars hence we fill the gap + # to make XSD validation happy in case it's empty. + # You are supposed to validate your barcode to match GTIN requirements. + return {"gtin": item.product_id.barcode or "".zfill(14)} + + def _shipment_item_avp_list(self, item): + """Hook to add avpList attributes. + + `avpList` can contain an arbitrary set of custom attributes. + Use `_make_avp_attribute()` to custom items and produce a list here. + + Eg: + [self._make_avp_attribute("vendorProductName", "Apple")] + + will generate an element like + + + Apple + + """ + return [] + + def _make_avp_attribute(self, attr_name, value): + """Generate `eComStringAttributeValuePairList` element.""" + return { + "eComStringAttributeValuePairList": { + "attrs": {"attributeName": attr_name}, + "value": value, + } + } diff --git a/edi_gs1_stock_oca/models/res_company.py b/edi_gs1_stock_oca/models/res_company.py new file mode 100644 index 000000000..a8d4b95e0 --- /dev/null +++ b/edi_gs1_stock_oca/models/res_company.py @@ -0,0 +1,15 @@ +# Copyright 2022 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + gs1_main_uom_id = fields.Many2one( + comodel_name="uom.uom", string="GS1 main UoM", help="Main UoM for GS1 files" + ) + + gs1_second_uom_id = fields.Many2one( + comodel_name="uom.uom", string="GS1 second UoM", help="Second UoM for GS1 files" + ) diff --git a/edi_gs1_stock_oca/models/res_config_settings.py b/edi_gs1_stock_oca/models/res_config_settings.py new file mode 100644 index 000000000..5ca428ec7 --- /dev/null +++ b/edi_gs1_stock_oca/models/res_config_settings.py @@ -0,0 +1,20 @@ +# Copyright 2022 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + gs1_main_uom_id = fields.Many2one( + comodel_name="uom.uom", + help="Main UoM for GS1 files", + related="company_id.gs1_main_uom_id", + readonly=False, + ) + gs1_second_uom_id = fields.Many2one( + comodel_name="uom.uom", + help="Second UoM for GS1 files", + related="company_id.gs1_second_uom_id", + readonly=False, + ) diff --git a/edi_gs1_stock_oca/models/stock_picking.py b/edi_gs1_stock_oca/models/stock_picking.py new file mode 100644 index 000000000..1e577cc66 --- /dev/null +++ b/edi_gs1_stock_oca/models/stock_picking.py @@ -0,0 +1,83 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +# TODO: inherit from exchange consumer + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + @api.model + def get_backend_by_delivery(self): + """Retrieve GS1 backend by given delivery order (stock.picking). + + You might have different LSP and pick up the right backend + based on the delivery order. + """ + # TODO: how do we handle this? + # We could have a wizard of some special fields to set by record + # which backend to use. + return self.env.ref("edi_gs1_oca.edi_backend_gs1_default") + + def _common_instruction(self, send, type_code): + delivery = self + edi_backend = self.get_backend_by_delivery() + values = {"model": delivery._name, "res_id": delivery.id} + exchange_record = edi_backend.create_record(type_code, values) + edi_backend.exchange_generate(exchange_record) + send = self.env.context.get("edi_exchange_send", send) + if send: + edi_backend.exchange_send(exchange_record) + return exchange_record + + # TODO: check if sending is required + def send_wh_inbound_instruction(self, send=False): + """Generate an Inbound Instruction for given delivery and send it.""" + type_code = "warehousingInboundInstruction" + return self._common_instruction(send, type_code) + + def action_send_wh_inbound_instruction(self): + # TODO: return action compat dict + return self.send_wh_inbound_instruction() + + def send_wh_outbound_instruction(self, send=False): + """Generate an Outbound Instruction for given delivery and send it.""" + type_code = "warehousingOutboundInstruction" + return self._common_instruction(send, type_code) + + def action_send_wh_outbound_instruction(self): + # TODO: return action compat dict + return self.send_wh_outbound_instruction() + + def action_stop_gs1(self): + exchange_records = self.env["edi.exchange.record"].search( + [("model", "=", self._name), ("res_id", "in", self.ids)] + ) + if exchange_records: + exchange_records.action_exchange_stop() + return {} + + def unlink(self): + """ + + :return: bool + """ + picking_ids = self.ids + result = super().unlink() + exchange_records = self.env["edi.exchange.record"].search( + [("model", "=", self._name), ("res_id", "in", picking_ids)] + ) + exchange_records.write({"model": False, "res_id": False}) + if exchange_records: + exchange_records.action_exchange_stop() + for exchange_record in exchange_records: + exchange_record.message_post(body=self.env._("Related picking deleted")) + return result diff --git a/edi_gs1_stock_oca/pyproject.toml b/edi_gs1_stock_oca/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/edi_gs1_stock_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_gs1_stock_oca/readme/CONTRIBUTORS.md b/edi_gs1_stock_oca/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..5792655b0 --- /dev/null +++ b/edi_gs1_stock_oca/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Simone Orsi \<\> diff --git a/edi_gs1_stock_oca/readme/DESCRIPTION.md b/edi_gs1_stock_oca/readme/DESCRIPTION.md new file mode 100644 index 000000000..b12be6ce1 --- /dev/null +++ b/edi_gs1_stock_oca/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +[GS1 -The Global Language of Business](http://gs1.org/) implementation +for stock. + +Provides support to generate or process the following messages: + +> - Warehouse Inbound Instruction \[WIP\] +> - Warehouse Inbound Notification \[TODO\] +> - Warehouse Outbound Instruction \[TODO\] +> - Warehouse Outbound Notification \[TODO\] diff --git a/edi_gs1_stock_oca/setup/.setuptools-odoo-make-default-ignore b/edi_gs1_stock_oca/setup/.setuptools-odoo-make-default-ignore new file mode 100644 index 000000000..207e61533 --- /dev/null +++ b/edi_gs1_stock_oca/setup/.setuptools-odoo-make-default-ignore @@ -0,0 +1,2 @@ +# addons listed in this file are ignored by +# setuptools-odoo-make-default (one addon per line) diff --git a/edi_gs1_stock_oca/setup/README b/edi_gs1_stock_oca/setup/README new file mode 100644 index 000000000..a63d633e8 --- /dev/null +++ b/edi_gs1_stock_oca/setup/README @@ -0,0 +1,2 @@ +To learn more about this directory, please visit +https://pypi.python.org/pypi/setuptools-odoo diff --git a/edi_gs1_stock_oca/static/description/icon.png b/edi_gs1_stock_oca/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/edi_gs1_stock_oca/static/description/icon.png differ diff --git a/edi_gs1_stock_oca/static/description/index.html b/edi_gs1_stock_oca/static/description/index.html new file mode 100644 index 000000000..f7acc42d5 --- /dev/null +++ b/edi_gs1_stock_oca/static/description/index.html @@ -0,0 +1,439 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

GS1 EDI for stock

+ +

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

+

GS1 -The Global Language of Business +implementation for stock.

+

Provides support to generate or process the following messages:

+
+
    +
  • Warehouse Inbound Instruction [WIP]
  • +
  • Warehouse Inbound Notification [TODO]
  • +
  • Warehouse Outbound Instruction [TODO]
  • +
  • Warehouse Outbound Notification [TODO]
  • +
+
+

Table of contents

+ +
+

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

+
    +
  • ACSONE
  • +
+
+
+

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-framework project on GitHub.

+

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

+
+
+
+
+ + diff --git a/edi_gs1_stock_oca/tests/__init__.py b/edi_gs1_stock_oca/tests/__init__.py new file mode 100644 index 000000000..2bfea355d --- /dev/null +++ b/edi_gs1_stock_oca/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_inbound_instruction +from . import test_outbound_instruction diff --git a/edi_gs1_stock_oca/tests/common.py b/edi_gs1_stock_oca/tests/common.py new file mode 100644 index 000000000..802e47400 --- /dev/null +++ b/edi_gs1_stock_oca/tests/common.py @@ -0,0 +1,89 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from uuid import uuid4 + +from odoo import fields +from odoo.tests import Form + +from odoo.addons.edi_gs1_oca.tests.common import BaseTestCase + + +class DeliveryMixin: + @classmethod + def _create_purchase_order(cls, values, view=None): + """Create a purchase order + + :return: purchase order + """ + po = Form(cls.env["purchase.order"], view=view) + po.partner_ref = str(uuid4())[:6] + po.date_planned = fields.Date.today() + for k, v in values.items(): + setattr(po, k, v) + return po.save() + + @classmethod + def _create_purchase_order_line(cls, purchase_order, view=None, **kw): + """ + Create a purchase order line for give order + :return: line + """ + values = {} + values.update(kw) + po = Form(purchase_order, view=view) + with po.order_line.new() as po_line: + for k, v in values.items(): + setattr(po_line, k, v) + return po.save() + + +class ShipmentTestCaseBase(BaseTestCase, DeliveryMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + @classmethod + def _create_product(cls, name, barcode): + return cls.env["product.product"].create( + { + "name": name, + "is_storable": True, + "type": "consu", + "barcode": barcode, + "weight": 1.0, + } + ) + + @classmethod + def _setup_order(cls): + cls.product_a = cls._create_product("GS1 Product A", "1" * 14) + cls.product_b = cls._create_product("GS1 Product B", "2" * 14) + cls.product_c = cls._create_product("GS1 Product C", "3" * 14) + cls.vendor = cls.env["res.partner"].create({"name": "GS1 Vendor"}) + cls.purchase = cls._create_purchase_order( + { + "partner_id": cls.vendor, + "date_planned": "2020-07-12", + } + ) + lines = [ + {"product_id": cls.product_a, "product_qty": 300}, + {"product_id": cls.product_b, "product_qty": 200}, + {"product_id": cls.product_c, "product_qty": 100}, + ] + for line in lines: + cls._create_purchase_order_line(cls.purchase, **line) + + cls.purchase.button_approve() + cls.delivery = cls.purchase.picking_ids[0] + cls.delivery.scheduled_date = "2020-07-12 12:00:00" + cls.carrier = cls.env["res.partner"].create( + { + "name": "GS1 Carrier", + "gln_code": "123".zfill(13), + "ref": "CARRIER#1", + } + ) diff --git a/edi_gs1_stock_oca/tests/test_inbound_instruction.py b/edi_gs1_stock_oca/tests/test_inbound_instruction.py new file mode 100644 index 000000000..6601b2c03 --- /dev/null +++ b/edi_gs1_stock_oca/tests/test_inbound_instruction.py @@ -0,0 +1,135 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from .common import ShipmentTestCaseBase + + +class InboundInstructionTestCase(ShipmentTestCaseBase): + _schema_path = ( + "edi_gs1_oca:static/schemas/gs1/ecom/WarehousingInboundInstruction.xsd" + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_order() + cls.exc_type = cls.env.ref( + "edi_gs1_stock_oca.edi_exchange_type_inbound_instruction" + ) + cls.handler = cls.env["edi.gs1.generate.inbound.instruction"] + vals = { + "model": cls.delivery._name, + "res_id": cls.delivery.id, + "type_id": cls.exc_type.id, + } + cls.record = cls.backend.create_record(cls.exc_type.code, vals) + + def test_generator_wired(self): + self.assertEqual( + self.exc_type.generate_model_id.model, + "edi.gs1.generate.inbound.instruction", + ) + + def test_work_ctx(self): + values = self.handler._get_work_ctx(self.record) + expected = [ + ("sender", self.backend.lsc_partner_id), + ("receiver", self.backend.lsp_partner_id), + ("ls_buyer", self.backend.lsc_partner_id), + ("ls_seller", self.backend.lsp_partner_id), + ("buyer", self.backend.lsc_partner_id), + ("seller", self.purchase.partner_id), + ] + for k, v in expected: + self.assertEqual(values[k], v) + self.assertTrue(values["shipper"]) + + @freeze_time("2020-07-09 10:30:00") + def test_info_provider_data(self): + work_ctx = self.handler._get_work_ctx(self.record) + work_ctx["shipper"] = self.carrier + expected_shipment = { + "shipmentIdentification": { + "additionalShipmentIdentification": { + "attrs": { + # fmt: off + "additionalShipmentIdentificationTypeCode": "GOODS_RECEIVER_ASSIGNED" # noqa: E501 + # fmt: on + }, + "value": self.delivery.name, + } + }, + "shipper": { + "gln_code": "0000000000123", + "additionalPartyIdentification": { + "attrs": { + # fmt: off + "additionalPartyIdentificationTypeCode": "BUYER_ASSIGNED_IDENTIFIER_FOR_A_PARTY" # noqa: E501 + # fmt: on + }, + "value": "CARRIER#1", + }, + }, + "packageTotal": [ + { + "packageTypeCode": "AF", + "totalPackageQuantity": "2", + "totalGrossWeight": { + "value": self.delivery.weight, + "attrs": {"measurementUnitCode": "KGM"}, + }, + } + ], + "warehousingReceiptTypeCode": "REGULAR_RECEIPT", + "plannedReceipt": {"logisticEventDateTime": {"date": "2020-07-12"}}, + "_shipment_items": [ + { + "lineItemNumber": 1, + "note": {}, + "transactionalTradeItem": {"gtin": "1" * 14}, + "inventoryDutyFeeTaxStatus": [], + "_plannedQty": { + "value": 300.0, + "attrs": {"measurementUnitCode": "KGM"}, + }, + }, + { + "lineItemNumber": 2, + "note": {}, + "transactionalTradeItem": {"gtin": "2" * 14}, + "inventoryDutyFeeTaxStatus": [], + "_plannedQty": { + "value": 200.0, + "attrs": {"measurementUnitCode": "KGM"}, + }, + }, + { + "lineItemNumber": 3, + "note": {}, + "transactionalTradeItem": {"gtin": "3" * 14}, + "inventoryDutyFeeTaxStatus": [], + "_plannedQty": { + "value": 100.0, + "attrs": {"measurementUnitCode": "KGM"}, + }, + }, + ], + } + info = self.handler.generate_info( + self.record, **work_ctx + ).warehousingInboundInstruction + for k, v in expected_shipment.items(): + self.assertEqual( + info.warehousingInboundInstructionShipment[k], v, f"{k} does not match" + ) + + @freeze_time("2020-07-09 10:30:00") + def test_xml(self): + record = self.delivery.with_context( + edi_exchange_send=False + ).action_send_wh_inbound_instruction() + file_content = record._get_file_content() + self.assertEqual(self._validate_xml(file_content), None) diff --git a/edi_gs1_stock_oca/tests/test_inbound_notification.py b/edi_gs1_stock_oca/tests/test_inbound_notification.py new file mode 100644 index 000000000..2fd3fcf9d --- /dev/null +++ b/edi_gs1_stock_oca/tests/test_inbound_notification.py @@ -0,0 +1,113 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +# from freezegun import freeze_time + +# from .common import ShipmentTestCaseBase + +# TODO: add inbound notification handling + + +# class InboundNotificationTestCase(ShipmentTestCaseBase): +# @classmethod +# def setUpClass(cls): +# super().setUpClass() +# cls._setup_order() +# cls.exc_type = cls.env.ref( +# "edi_gs1_stock.edi_exchange_type_inbound_notification" +# ) +# cls.exc_tmpl = cls.env.ref( +# "edi_gs1_stock.edi_exchange_template_inbound_notification" +# ) +# vals = { +# "model": cls.delivery._name, +# "res_id": cls.delivery.id, +# "type_id": cls.exc_type.id, +# "edi_exchange_state": "input_received", +# # TODO: set xml value +# } +# cls.record = cls.backend.create_record(cls.exc_type.code, vals) + +# @freeze_time("2020-07-09 10:30:00") +# def test_info_provider_data(self): +# values = self.exc_tmpl._get_render_values(self.record, shipper=self.carrier) +# provider = self.exc_tmpl._get_info_provider(self.record, work_ctx=values) +# expected_shipment = { +# "shipmentIdentification": { +# "additionalShipmentIdentification": { +# "attrs": { +# # fmt: off +# "additionalShipmentIdentificationTypeCode": +# "GOODS_RECEIVER_ASSIGNED" +# # fmt: on +# }, +# "value": self.delivery.name, +# } +# }, +# "shipper": { +# "gln_code": "0000000000123", +# "additionalPartyIdentification": { +# "attrs": { +# # fmt: off +# "additionalPartyIdentificationTypeCode": +# "BUYER_ASSIGNED_IDENTIFIER_FOR_A_PARTY" +# # fmt: on +# }, +# "value": "CARRIER#1", +# }, +# }, +# "packageTotal": { +# "packageTypeCode": "AF", +# "totalPackageQuantity": "2", +# "totalGrossWeight": { +# "value": self.delivery.weight, +# "attrs": {"measurementUnitCode": "KGM"}, +# }, +# }, +# "warehousingReceiptTypeCode": "REGULAR_RECEIPT", +# "plannedReceipt": {"logisticEventDateTime": {"date": "2020-07-12"}}, +# "_shipment_items": [ +# { +# "lineItemNumber": 1, +# "transactionalTradeItem": {"gtin": "1" * 14}, +# "_plannedQty": { +# "value": 300.0, +# "attrs": {"measurementUnitCode": "KGM"}, +# }, +# }, +# { +# "lineItemNumber": 2, +# "transactionalTradeItem": {"gtin": "2" * 14}, +# "_plannedQty": { +# "value": 200.0, +# "attrs": {"measurementUnitCode": "KGM"}, +# }, +# }, +# { +# "lineItemNumber": 3, +# "transactionalTradeItem": {"gtin": "3" * 14}, +# "_plannedQty": { +# "value": 100.0, +# "attrs": {"measurementUnitCode": "KGM"}, +# }, +# }, +# ], +# } +# info = provider.generate_info().warehousingInboundnotification +# for k, v in expected_shipment.items(): +# self.assertEqual( +# info.warehousingInboundnotificationShipment[k], +# v, f"{k} does not match" +# ) + +# @freeze_time("2020-07-09 10:30:00") +# def test_xml(self): +# record = self.delivery.with_context( +# edi_exchange_send=False +# ).action_send_wh_inbound_notification() +# file_content = record._get_file_content() +# # FIXME: do proper validation +# self.assertTrue( +# file_content.startswith(" +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from .common import ShipmentTestCaseBase + + +class OutboundInstructionTestCase(ShipmentTestCaseBase): + _schema_path = ( + "edi_gs1_oca:static/schemas/gs1/ecom/WarehousingOutboundInstruction.xsd" + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_order() + cls.exc_type = cls.env.ref( + "edi_gs1_stock_oca.edi_exchange_type_outbound_instruction" + ) + cls.handler = cls.env["edi.gs1.generate.outbound.instruction"] + vals = { + "model": cls.delivery._name, + "res_id": cls.delivery.id, + "type_id": cls.exc_type.id, + } + cls.record = cls.backend.create_record(cls.exc_type.code, vals) + + def test_generator_wired(self): + self.assertEqual( + self.exc_type.generate_model_id.model, + "edi.gs1.generate.outbound.instruction", + ) + + def test_work_ctx(self): + values = self.handler._get_work_ctx(self.record) + expected = [ + ("sender", self.backend.lsc_partner_id), + ("receiver", self.delivery.partner_id), + ("ls_buyer", self.backend.lsc_partner_id), + ("ls_seller", self.backend.lsp_partner_id), + ("buyer", self.delivery.partner_id), + ("seller", self.backend.lsc_partner_id), + ] + for k, v in expected: + self.assertEqual(values[k], v, f"{k} is wrong") + self.assertTrue(values["shipper"]) + + @freeze_time("2020-07-09 10:30:00") + def test_info_provider_data(self): + work_ctx = self.handler._get_work_ctx(self.record) + work_ctx["shipper"] = self.carrier + expected_shipment = { + "shipmentIdentification": { + "additionalShipmentIdentification": { + "attrs": { + # fmt: off + "additionalShipmentIdentificationTypeCode": "GOODS_RECEIVER_ASSIGNED" # noqa: E501 + # fmt: on + }, + "value": self.delivery.name, + } + }, + "shipper": { + "gln_code": "0000000000123", + "additionalPartyIdentification": { + "attrs": { + # fmt: off + "additionalPartyIdentificationTypeCode": "BUYER_ASSIGNED_IDENTIFIER_FOR_A_PARTY" # noqa: E501 + # fmt: on + }, + "value": "CARRIER#1", + }, + }, + "packageTotal": [ + { + "packageTypeCode": "AF", + "totalPackageQuantity": "2", + "totalGrossWeight": { + "value": self.delivery.weight, + "attrs": {"measurementUnitCode": "KGM"}, + }, + } + ], + "warehousingDespatchTypeCode": "WAREHOUSE_SHIPMENT", + "plannedDespatch": {"logisticEventPeriod": {"beginDate": "2020-07-12"}}, + "_shipment_items": [ + { + "lineItemNumber": 1, + "note": {}, + "transactionalTradeItem": {"gtin": "1" * 14}, + "inventoryDutyFeeTaxStatus": [], + "_plannedQty": { + "value": 300.0, + "attrs": {"measurementUnitCode": "KGM"}, + }, + }, + { + "lineItemNumber": 2, + "note": {}, + "transactionalTradeItem": {"gtin": "2" * 14}, + "inventoryDutyFeeTaxStatus": [], + "_plannedQty": { + "value": 200.0, + "attrs": {"measurementUnitCode": "KGM"}, + }, + }, + { + "lineItemNumber": 3, + "note": {}, + "transactionalTradeItem": {"gtin": "3" * 14}, + "inventoryDutyFeeTaxStatus": [], + "_plannedQty": { + "value": 100.0, + "attrs": {"measurementUnitCode": "KGM"}, + }, + }, + ], + } + info = self.handler.generate_info( + self.record, **work_ctx + ).warehousingOutboundInstruction + for k, v in expected_shipment.items(): + self.assertEqual( + info.warehousingOutboundInstructionShipment[k], v, f"{k} does not match" + ) + + @freeze_time("2020-07-09 10:30:00") + def test_xml(self): + record = self.delivery.with_context( + edi_exchange_send=False + ).action_send_wh_outbound_instruction() + file_content = record._get_file_content() + self.assertEqual(self._validate_xml(file_content), None) diff --git a/edi_gs1_stock_oca/views/res_config_settings.xml b/edi_gs1_stock_oca/views/res_config_settings.xml new file mode 100644 index 000000000..71b6b5ba6 --- /dev/null +++ b/edi_gs1_stock_oca/views/res_config_settings.xml @@ -0,0 +1,33 @@ + + + + + res.config.settings.form (in edi_gs1_stock) + res.config.settings + + + + + + + + + + + + + + + + diff --git a/edi_gs1_stock_oca/views/stock_picking_view.xml b/edi_gs1_stock_oca/views/stock_picking_view.xml new file mode 100644 index 000000000..dc4510914 --- /dev/null +++ b/edi_gs1_stock_oca/views/stock_picking_view.xml @@ -0,0 +1,35 @@ + + + + + stock.picking.form (in gs1_stock) + stock.picking + + + + +
+
+
+
+
diff --git a/requirements.txt b/requirements.txt index 4ec60a70e..d950e810d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ # generated from manifests external_dependencies PyYAML openupgradelib +xmlschema +xmlunittest