Files
enterprise-addons/stock_barcode/models/epc_encoder.py
T

349 lines
16 KiB
Python

import math
import re
import urllib
from functools import wraps, cache
from lxml import etree
from odoo import _
from odoo.tools import OrderedSet
from odoo.tools.misc import file_path
DEFAULT_BIT_WORD_SIZE = 8
LSBF = 'little'
MSBF = 'big'
DEFAULT_ENDIAN = MSBF
class EpcScheme:
def __init__(self, name):
"""
Parameters:
name (str): the name of the scheme to initialize. It MUST follows the GS1 URI convention
"""
self.name = name
scheme_data = EpcData.get_scheme(name, as_dict=True)
self.header = int(scheme_data['header_value'], 16)
self.bit_size = int(scheme_data['bit_size'])
self.partition_table = EpcData.get_partition_table(scheme_data.get('partition_table', None))
self.fields = []
for field in scheme_data['fields_list']['field']:
self.fields.append(EpcField(self, field['name'], int(field['bit_size'])))
if field['name'] == 'header':
self.fields[-1].value = self.header
if 'ai' in field:
self.fields[-1].ai = field['ai']
def encode(self, element_string, filter, company_prefix_length):
if company_prefix_length and not 6 <= company_prefix_length <= 12:
raise Exception(_("An error occured: value of company_prefix_length (%(company_prefix_length)s) must be between 6 and 12 inclusive.", company_prefix_length=company_prefix_length))
ai_dict = EpcData.element_string_to_dict(element_string)
partition_line = self.partition_table.get(company_prefix_length, None)
next_values = None
for field in self.fields:
if field.name == 'filter':
field.value = filter
elif field.name == 'partition':
if not partition_line:
raise Exception(_("An error occurred with field 'partition': no partition value can be found for the given company_prefix_length"))
to_process = next((key, value) if key == self.partition_table['ai'] else None for key, value in ai_dict.items())
if not to_process: # The presence of the partition field is always linked to the mandatory presence of an AI to split data from in the element string
return _("An error occured during parsing of element_string: AI code %(ai_code)s is missing for scheme %(scheme_name)s", ai_code=self.partition_table['ai'], scheme_name=self.name)
next_values = EpcData.split_ai(to_process, company_prefix_length)
field.value = partition_line['value']
elif next_values and len(next_values):
if field.bit_size == 0:
if field.name == 'company_prefix':
field.bit_size = partition_line['left_bit']
else:
field.bit_size = partition_line['right_bit']
field.value = next_values.pop(0)
elif field.ai and field.ai in ai_dict:
field.value = ai_dict[field.ai]
elif field.name != 'header':
return _("An error occured with field '%(field_name)s': the corresponding AI '%(ai_code)s' was not found in the element_string", field_name=field.name, ai_code=field.ai)
try:
field._encode()
except Exception as e:
message = _("An error occurred with field '%(field_name)s': %(error_message)s", field_name=field.name, error_message=e)
if field.name in ('header', 'filter', 'partition'):
# When an exception happens on these fields, all incoming encoding using this scheme will fail or be corrupted
raise Exception(message) from e
# Else, just warn the user for this specific data and let the process continue
return message
return str(self)
def encode_partial(self, field_name, field_value):
"""To use with care: this method will not check the validity of the field value"""
if field_name in ('header', 'filter', 'partition'):
return Exception(_("Field '%(field_name)s' is a reserved field, you can't manually update its value with this method", field_name=field_name))
for field in self.fields:
if field.name == field_name:
field.value = field_value
try:
field._encode()
return str(self)
except Exception as e:
return _("An error occurred with field '%(field_name)s': %(error_message)s", field_name=field.name, error_message=e)
return _("Field '%(field_name)s' not found", field_name=field_name)
def to_dict(self):
return {
'name': self.name,
'header': self.header,
'bit_size': self.bit_size,
'partition_table': self.partition_table,
'fields': [field.to_dict() for field in self.fields]
}
def __str__(self):
total_size = EpcData.normalize_epc_length(sum(len(field.raw_value) for field in self.fields))
bit_string = ''.join(field.raw_value for field in self.fields).ljust(total_size, '0')
return f"{int(bit_string, 2):x}"
class EpcField:
def __init__(self, scheme, name, bit_size):
self.scheme = scheme
field_data = EpcData.get_field(name, as_dict=True)
self.name = field_data['name']
self.encoding = field_data['encoding']
self.bit_size = bit_size
self.value = 0
self.raw_value = 0
self.ai = None
def to_dict(self):
return {
'name': self.name,
'encoding': self.encoding,
'bit_size': self.bit_size,
'value': self.value,
'raw_value': self.raw_value,
}
def _encode(self):
method_name = f'_encode_{self.encoding}'
if hasattr(self, method_name):
try:
self.raw_value = getattr(self, method_name)()
except Exception as e:
raise Exception(_("An error occurred with method '%(method_name)s': %(error_message)s", method_name=method_name, error_message=e)) from e
else:
raise AttributeError(_("The encoding '%(encoding_name)s' is not supported.", encoding_name=self.encoding))
def _encode_integer(self):
return EpcData.write_bits(int(self.value), self.bit_size)
def _encode_string(self):
self._check_character_set()
formatted_value = self.ascii_to_bits(urllib.parse.unquote(self.value))
return EpcData.write_bits(formatted_value, self.bit_size, word_size_write=7, pad_side='right')
def _encode_partition_table(self):
return self._encode_integer()
def ascii_to_bits(self, input_string):
return int.from_bytes(input_string.encode('ascii'), byteorder=DEFAULT_ENDIAN)
def _check_character_set(self):
# c.f. [TDS 2.1] §A
# Allowed characters: A-Z, a-z, 0-9, !"%&'()*+,-./_:;<=>?
# Notice some special characters are not allowed, even if they're part of ASCII : (Space) @#$[]^`{|}~
invalid_chars = OrderedSet(''.join(re.findall(r'[^A-Za-z0-9!"%&\'()*+,-./_:;<=>?]*', self.value)))
if invalid_chars:
raise Exception(_("Value to encode contains invalid character(s): %(invalid_chars)s", invalid_chars="`" + "`, `".join(invalid_chars) + "`"))
class EpcData:
@classmethod
def element_string_to_dict(cls, element_string):
"""Extracts a list of tuples from an element string."""
return dict(re.findall(r'(?:\(\s*(\d{2,4})\s*\)\s*([^\(\)\s]+)?)', element_string))
@classmethod
def split_ai(cls, element, company_prefix_length):
""" With Schemes from TDS < 2.0, EPC encoding need to separate the company prefix
from the other values it is associated with in the AI code.
It may also be necessary to rearrange some digits depending on the AI code.
See TDS 2.1 § 7
input : single ai code as tuple eg ('01', '12345')
output : list of 2 to 4 elements (e.g. ITIP scheme extract 4 data from a single (8006) AI code)
Remaining AI : scheme -> number of elements
00 : sscc -> 2; 414 : sgln -> 2; 8003 : grai -> 3; 8004 : giai -> 2; 8018 : gsrn -> 2
8017 : gsrnp -> 2; 253 : gdti -> 3; 8010 : cpi -> 2; 255 : sgcn -> 3; 8006 : itip -> 4
?Not related to a binary scheme : (401 : ginc -> 2; 402 : gsin -> 2; 417 : pgln -> 2)
"""
if element[0] == '01':
# GTIN "A.BBBBBBB.CCCCC.D" -> [CompanyPrefix: BBBBBBB, ItemRef with indicator: ACCCCC]
value = element[1].rjust(14, '0') # Enforce length to be 14 as required by GS1 specification for consistent result
return [value[1:1+company_prefix_length], value[0:1] + value[1+company_prefix_length:-1]]
return None
@classmethod
def write_bits(cls, value, length, pad_side='left', pad_char='0', word_size_write=DEFAULT_BIT_WORD_SIZE, word_size_read=DEFAULT_BIT_WORD_SIZE, word_pad_side='left', word_pad_char='0'):
"""
Converts an integer value to its binary representation with a specified length and padding side.
Args:
value (int): The integer value to be converted.
length (int): The desired length of the binary representation.
pad_side (str, optional): The side on which to pad the final binary representation ('left' or 'right'). Default is 'left'.
pad_char (str, optional): The character to use for padding. Default is '0'.
word_size (int, optional): The fixed size of the binary representation of each word. Default is 8. Must be in [1,8].
N.B. Actually, EPC nomenclature rely on 8-bit words encoding on inputs (ASCII, ...).
word_pad_side (str, optional): The side on which to pad the final binary representation of each word ('left' or 'right'). Default is 'left'.
word_pad_char (str, optional): The character to use for word padding. Default is '0'.
Returns:
str: The binary representation of the value with the specified length.
If the length is less than the actual length of the binary representation,
leading or trailing zeros are added to make it the desired length.
Raises:
ValueError: If the value cannot be represented with the specified length.
Example:
write_bits(5, 8) -> '00000101'
write_bits(5, 8, pad_side='right') -> '10100000'
"""
if length <= 0:
raise ValueError(_("Length must be a positive integer"))
if word_size_write <= 0 :
raise ValueError(_("Word size must be a positive integer"))
tot_bits = value.bit_length()
if tot_bits > length:
raise ValueError(_("Value %(value)s cannot be represented with %(length)s bits", value=value, length=length))
if pad_side not in ['left', 'right']:
raise ValueError(_("Invalid pad_side '%(pad_side)s'. Use 'left' or 'right'.", pad_side=pad_side))
if word_pad_side not in ['left', 'right']:
raise ValueError(_("Invalid word_pad_side '%(word_pad_side)s'. Use 'left' or 'right'.", word_pad_side=word_pad_side))
if pad_char not in ['0', '1']:
raise ValueError(_("Invalid pad_char '%(pad_char)s'. Use '0' or '1'.", pad_char=pad_char))
if word_pad_char not in ['0', '1']:
raise ValueError(_("Invalid word_pad_char '%(word_pad_char)s'. Use '0' or '1'.", word_pad_char=word_pad_char))
if not isinstance(value, int):
raise ValueError(_("Value '%(value)s' must be an integer", value))
if length <= word_size_write or word_size_write == word_size_read:
bit_string = f"{value:{pad_char}{length}b}"
else:
smaller = word_size_write < word_size_read
word_number = math.ceil(tot_bits / word_size_read)
normalized_bits = word_number * word_size_read
default_bit_string = f"{value:{0}{normalized_bits}b}"
bit_array = []
for i in range(word_number):
word = default_bit_string[word_size_read*i:word_size_read*(i+1)]
if smaller:
if int(word[:word_size_read-word_size_write], 2) != 0:
raise ValueError(_("Binary value %(word)s cannot be represented with %(word_size_write)s bits", word=word, word_size_write=word_size_write))
bit_array.append(word[word_size_read-word_size_write:])
elif word_pad_side == 'left':
bit_array.append(word.rjust(word_size_write, word_pad_char))
else:
bit_array.append(word.ljust(word_size_write, word_pad_char))
bit_string = ''.join(bit_array)
if pad_side == 'left':
padded_bit_string = bit_string.rjust(length, pad_char) # May seem confusing but rjust = right justify, so it means that the string is padded on the left
else:
padded_bit_string = bit_string.ljust(length, pad_char)
return padded_bit_string
@classmethod
def normalize_epc_length(cls, raw_length):
# EPC encoding must have a multiple of 16 bits.
# c.f. [TDS 2.1], §15.1.1
return raw_length + cls._missing_epc_bit(raw_length)
@classmethod
def _missing_epc_bit(cls, raw_length):
remainder = raw_length % 16
return (16 - remainder) if remainder else 0
@classmethod
def xml_to_dict(cls, func):
"""Extend the function with the as_dict parameter to return the XML element as a list of dicts when set to True"""
@wraps(func)
def wrapper_func(*args, **kwargs):
as_dict = kwargs.pop('as_dict', False)
xml_element = func(*args, **kwargs)
if as_dict:
return cls._parse_xml_as_dict(xml_element)
return xml_element
return wrapper_func
@classmethod
@cache
def _xml_tree(cls):
return etree.parse(file_path('stock_barcode/data/epc_template.xml'))
@classmethod
@cache
def get_scheme(cls, scheme_name):
return cls._xml_tree().xpath(f"//epc_template/schemes/scheme[@name='{scheme_name}']")
@classmethod
@cache
def get_partition_table(cls, table_name):
if not table_name:
return {}
partition_table = cls._parse_xml_as_dict(cls._xml_tree().xpath(f"//epc_template/partition_tables/partition_table[@name='{table_name}']"))
if not partition_table:
raise Exception(_("Partition table %(table_name)s not found, available tables are : %(table_list)s)", table_name=table_name, table_list=', '.join(cls._xml_tree().xpath('//epc_template/partition_tables/partition_table/@name'))))
res = {'ai': partition_table['ai_required']}
res.update(
{int(key['left_digit']): {
'value': int(key['value']),
'left_bit': int(key['left_bit']),
'right_bit': int(key['right_bit']),
'right_digit': int(key['right_digit'])
} for key in partition_table['key']})
return res
@classmethod
@cache
def get_field(cls, field_name):
return cls._xml_tree().xpath(f"//epc_template/fields/field[@name='{field_name}']")
@classmethod
def _parse_xml_as_dict(cls, element):
"""Return a list of dicts"""
def _element_to_dict(elem):
node = {}
# Process element's attributes
if elem.attrib:
node.update((k, v) for k, v in elem.attrib.items())
# Process element's children
for child in elem:
child_dict = _element_to_dict(child)
if child.tag not in node:
node[child.tag] = child_dict
else:
if not isinstance(node[child.tag], list):
node[child.tag] = [node[child.tag]]
node[child.tag].append(child_dict)
# Process element's text content
if elem.text and elem.text.strip():
text = elem.text.strip()
if node:
node['text'] = text
else:
node = text
return node
if not element:
return None
if isinstance(element, list) and len(element) > 1:
return [_element_to_dict(elem) for elem in element]
return _element_to_dict(element[0])
# Apply xml_to_dict after @classmethod using an explicit wrapping step.
EpcData.get_scheme = EpcData.xml_to_dict(EpcData.get_scheme)
EpcData.get_field = EpcData.xml_to_dict(EpcData.get_field)