Initial commit: Technical packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 3473fa71a0
873 changed files with 297766 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from . import barcode_nomenclature
from . import barcode_rule
from . import ir_http

View file

@ -0,0 +1,178 @@
import re
import datetime
import calendar
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools import get_barcode_check_digit
FNC1_CHAR = '\x1D'
class BarcodeNomenclature(models.Model):
_inherit = 'barcode.nomenclature'
is_gs1_nomenclature = fields.Boolean(
string="Is GS1 Nomenclature",
help="This Nomenclature use the GS1 specification, only GS1-128 encoding rules is accepted is this kind of nomenclature.")
gs1_separator_fnc1 = fields.Char(
string="FNC1 Separator", trim=False, default=r'(Alt029|#|\x1D)',
help="Alternative regex delimiter for the FNC1. The separator must not match the begin/end of any related rules pattern.")
@api.constrains('gs1_separator_fnc1')
def _check_pattern(self):
for nom in self:
if nom.is_gs1_nomenclature and nom.gs1_separator_fnc1:
try:
re.compile("(?:%s)?" % nom.gs1_separator_fnc1)
except re.error as error:
raise ValidationError(_("The FNC1 Separator Alternative is not a valid Regex: ") + str(error))
@api.model
def gs1_date_to_date(self, gs1_date):
""" Converts a GS1 date into a datetime.date.
:param gs1_date: A year formated as yymmdd
:type gs1_date: str
:return: converted date
:rtype: datetime.date
"""
# See 7.12 Determination of century in dates:
# https://www.gs1.org/sites/default/files/docs/barcodes/GS1_General_Specifications.pdf
now = datetime.date.today()
current_century = now.year // 100
substract_year = int(gs1_date[0:2]) - (now.year % 100)
century = (51 <= substract_year <= 99 and current_century - 1) or\
(-99 <= substract_year <= -50 and current_century + 1) or\
current_century
year = century * 100 + int(gs1_date[0:2])
if gs1_date[-2:] == '00': # Day is not mandatory, when not set -> last day of the month
date = datetime.datetime.strptime(str(year) + gs1_date[2:4], '%Y%m')
date = date.replace(day=calendar.monthrange(year, int(gs1_date[2:4]))[1])
else:
try:
date = datetime.datetime.strptime(str(year) + gs1_date[2:], '%Y%m%d')
except ValueError as e:
raise ValidationError(_(
"A GS1 barcode nomenclature pattern was matched. However, the barcode failed to be converted to a valid date: '%(error_message)'",
error_message=e
))
return date.date()
def parse_gs1_rule_pattern(self, match, rule):
result = {
'rule': rule,
'ai': match.group(1),
'string_value': match.group(2),
}
if rule.gs1_content_type == 'measure':
try:
decimal_position = 0 # Decimal position begins at the end, 0 means no decimal.
if rule.gs1_decimal_usage:
decimal_position = int(match.group(1)[-1])
if decimal_position > 0:
result['value'] = float(match.group(2)[:-decimal_position] + "." + match.group(2)[-decimal_position:])
else:
result['value'] = int(match.group(2))
except Exception:
raise ValidationError(_(
"There is something wrong with the barcode rule \"%s\" pattern.\n"
"If this rule uses decimal, check it can't get sometime else than a digit as last char for the Application Identifier.\n"
"Check also the possible matched values can only be digits, otherwise the value can't be casted as a measure.",
rule.name))
elif rule.gs1_content_type == 'identifier':
# Check digit and remove it of the value
if match.group(2)[-1] != str(get_barcode_check_digit("0" * (18 - len(match.group(2))) + match.group(2))):
return None
result['value'] = match.group(2)
elif rule.gs1_content_type == 'date':
if len(match.group(2)) != 6:
return None
result['value'] = self.gs1_date_to_date(match.group(2))
else: # when gs1_content_type == 'alpha':
result['value'] = match.group(2)
return result
def gs1_decompose_extanded(self, barcode):
"""Try to decompose the gs1 extanded barcode into several unit of information using gs1 rules.
Return a ordered list of dict
"""
self.ensure_one()
separator_group = FNC1_CHAR + "?"
if self.gs1_separator_fnc1:
separator_group = "(?:%s)?" % self.gs1_separator_fnc1
# zxing-library patch, removing GS1 identifiers
for identifier in [']C1', ']e0', ']d2', ']Q3', ']J1', FNC1_CHAR]:
if barcode.startswith(identifier):
barcode = barcode.replace(identifier, '', 1)
break
results = []
gs1_rules = self.rule_ids.filtered(lambda r: r.encoding == 'gs1-128')
def find_next_rule(remaining_barcode):
for rule in gs1_rules:
match = re.search("^" + rule.pattern + separator_group, remaining_barcode)
# If match and contains 2 groups at minimun, the first one need to be the AI and the second the value
# We can't use regex nammed group because in JS, it is not the same regex syntax (and not compatible in all browser)
if match and len(match.groups()) >= 2:
res = self.parse_gs1_rule_pattern(match, rule)
if res:
return res, remaining_barcode[match.end():]
return None
while len(barcode) > 0:
res_bar = find_next_rule(barcode)
# Cannot continue -> Fail to decompose gs1 and return
if not res_bar or res_bar[1] == barcode:
return None
barcode = res_bar[1]
results.append(res_bar[0])
return results
def parse_barcode(self, barcode):
if self.is_gs1_nomenclature:
return self.gs1_decompose_extanded(barcode)
return super().parse_barcode(barcode)
@api.model
def _preprocess_gs1_search_args(self, args, barcode_types, field='barcode'):
"""Helper method to preprocess 'args' in _search method to add support to
search with GS1 barcode result.
Cut off the padding if using GS1 and searching on barcode. If the barcode
is only digits to keep the original barcode part only.
"""
nomenclature = self.env.company.nomenclature_id
if nomenclature.is_gs1_nomenclature:
for i, arg in enumerate(args):
if not isinstance(arg, (list, tuple)) or len(arg) != 3:
continue
field_name, operator, value = arg
if field_name != field or operator not in ['ilike', 'not ilike', '=', '!='] or value is False:
continue
parsed_data = []
try:
parsed_data += nomenclature.parse_barcode(value) or []
except (ValidationError, ValueError):
pass
replacing_operator = 'ilike' if operator in ['ilike', '='] else 'not ilike'
for data in parsed_data:
data_type = data['rule'].type
value = data['value']
if data_type in barcode_types:
match = re.match('0*([0-9]+)$', str(value))
if match:
unpadded_barcode = match.groups()[0]
args[i] = (field_name, replacing_operator, unpadded_barcode)
break
# The barcode isn't a valid GS1 barcode, checks if it can be unpadded.
if not parsed_data:
match = re.match('0+([0-9]+)$', value)
if match:
args[i] = (field_name, replacing_operator, match.groups()[0])
return args

View file

@ -0,0 +1,69 @@
import re
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class BarcodeRule(models.Model):
_inherit = 'barcode.rule'
def _default_encoding(self):
return 'gs1-128' if self.env.context.get('is_gs1') else 'any'
encoding = fields.Selection(
selection_add=[('gs1-128', 'GS1-128')], default=_default_encoding,
ondelete={'gs1-128': 'set default'})
type = fields.Selection(
selection_add=[
('quantity', 'Quantity'),
('location', 'Location'),
('location_dest', 'Destination location'),
('lot', 'Lot number'),
('package', 'Package'),
('use_date', 'Best before Date'),
('expiration_date', 'Expiration Date'),
('package_type', 'Package Type'),
('pack_date', 'Pack Date'),
], ondelete={
'quantity': 'set default',
'location': 'set default',
'location_dest': 'set default',
'lot': 'set default',
'package': 'set default',
'use_date': 'set default',
'expiration_date': 'set default',
'package_type': 'set default',
'pack_date': 'set default',
})
is_gs1_nomenclature = fields.Boolean(related="barcode_nomenclature_id.is_gs1_nomenclature")
gs1_content_type = fields.Selection([
('date', 'Date'),
('measure', 'Measure'),
('identifier', 'Numeric Identifier'),
('alpha', 'Alpha-Numeric Name'),
], string="GS1 Content Type",
help="The GS1 content type defines what kind of data the rule will process the barcode as:\
* Date: the barcode will be converted into a Odoo datetime;\
* Measure: the barcode's value is related to a specific UoM;\
* Numeric Identifier: fixed length barcode following a specific encoding;\
* Alpha-Numeric Name: variable length barcode.")
gs1_decimal_usage = fields.Boolean('Decimal', help="If True, use the last digit of AI to determine where the first decimal is")
associated_uom_id = fields.Many2one('uom.uom')
@api.constrains('pattern')
def _check_pattern(self):
gs1_rules = self.filtered(lambda rule: rule.encoding == 'gs1-128')
for rule in gs1_rules:
try:
re.compile(rule.pattern)
except re.error as error:
raise ValidationError(_("The rule pattern \"%s\" is not a valid Regex: ", rule.name) + str(error))
groups = re.findall(r'\([^)]*\)', rule.pattern)
if len(groups) != 2:
raise ValidationError(_(
"The rule pattern \"%s\" is not valid, it needs two groups:"
"\n\t- A first one for the Application Identifier (usually 2 to 4 digits);"
"\n\t- A second one to catch the value.",
rule.name))
super(BarcodeRule, (self - gs1_rules))._check_pattern()

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
# TODO: remove in master.
def session_info(self):
res = super().session_info()
nomenclature = self.env.company.sudo().nomenclature_id
if nomenclature.is_gs1_nomenclature:
res['gs1_group_separator_encodings'] = nomenclature.gs1_separator_fnc1
return res