Skip to content

Commit 210b9ce

Browse files
committed
Fix handling of decimals in Application Identifiers
This fixes the handling of GS1-128 Application Identifiers with decimal types. Previously, with some 4 digit decimal application identifiers the number of decimals were incorrectly considered part of the value instead of the application identifier. For example (310)5033333 is not correct but it should be evaluated as (3105)033333 (application identifier 3105 and value 0.33333). Closes #471
1 parent d66998e commit 210b9ce

File tree

4 files changed

+213
-173
lines changed

4 files changed

+213
-173
lines changed

stdnum/gs1_128.py

Lines changed: 118 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# gs1_128.py - functions for handling GS1-128 codes
22
#
33
# Copyright (C) 2019 Sergi Almacellas Abellana
4-
# Copyright (C) 2020-2024 Arthur de Jong
4+
# Copyright (C) 2020-2025 Arthur de Jong
55
#
66
# This library is free software; you can redistribute it and/or
77
# modify it under the terms of the GNU Lesser General Public
@@ -86,103 +86,135 @@ def compact(number: str) -> str:
8686
return clean(number, '()').strip()
8787

8888

89-
def _encode_value(fmt: str, _type: str, value: object) -> str:
89+
def _encode_decimal(ai: str, fmt: str, value: object) -> tuple[str, str]:
90+
"""Encode the specified decimal value given the format."""
91+
# For decimal types the last digit of the AI is used to encode the
92+
# number of decimal places (we replace the last digit)
93+
if isinstance(value, (list, tuple)) and fmt.startswith('N3+'):
94+
# Two numbers, where the number of decimal places is expected to apply
95+
# to the second value
96+
ai, number = _encode_decimal(ai, fmt[3:], value[1])
97+
return ai, str(value[0]).rjust(3, '0') + number
98+
value = str(value)
99+
if fmt.startswith('N..'):
100+
# Variable length number up to a certain length
101+
length = int(fmt[3:])
102+
value = value[:length + 1]
103+
number, decimals = (value.split('.') + [''])[:2]
104+
decimals = decimals[:9]
105+
return ai[:-1] + str(len(decimals)), number + decimals
106+
else:
107+
# Fixed length numeric
108+
length = int(fmt[1:])
109+
value = value[:length + 1]
110+
number, decimals = (value.split('.') + [''])[:2]
111+
decimals = decimals[:9]
112+
return ai[:-1] + str(len(decimals)), (number + decimals).rjust(length, '0')
113+
114+
115+
def _encode_date(fmt: str, value: object) -> str:
116+
"""Encode the specified date value given the format."""
117+
if isinstance(value, (list, tuple)) and fmt in ('N6..12', 'N6[+N6]'):
118+
# Two date values
119+
return '%s%s' % (
120+
_encode_date('N6', value[0]),
121+
_encode_date('N6', value[1]),
122+
)
123+
elif isinstance(value, datetime.date):
124+
# Format date in different formats
125+
if fmt in ('N6', 'N6..12', 'N6[+N6]'):
126+
return value.strftime('%y%m%d')
127+
elif fmt == 'N10':
128+
return value.strftime('%y%m%d%H%M')
129+
elif fmt in ('N6+N..4', 'N6[+N..4]', 'N6[+N4]'):
130+
value = value.strftime('%y%m%d%H%M')
131+
if value.endswith('00'):
132+
value = value[:-2]
133+
if value.endswith('00'):
134+
value = value[:-2]
135+
return value
136+
elif fmt in ('N8+N..4', 'N8[+N..4]'):
137+
value = value.strftime('%y%m%d%H%M%S')
138+
if value.endswith('00'):
139+
value = value[:-2]
140+
if value.endswith('00'):
141+
value = value[:-2]
142+
return value
143+
else: # pragma: no cover (all formats should be covered)
144+
raise ValueError('unsupported format: %s' % fmt)
145+
else:
146+
# Value is assumed to be in the correct format already
147+
return str(value)
148+
149+
150+
def _encode_value(ai: str, fmt: str, _type: str, value: object) -> tuple[str, str]:
90151
"""Encode the specified value given the format and type."""
91152
if _type == 'decimal':
92-
if isinstance(value, (list, tuple)) and fmt.startswith('N3+'):
93-
number = _encode_value(fmt[3:], _type, value[1])
94-
assert isinstance(value[0], str)
95-
return number[0] + value[0].rjust(3, '0') + number[1:]
96-
value = str(value)
97-
if fmt.startswith('N..'):
98-
length = int(fmt[3:])
99-
value = value[:length + 1]
100-
number, digits = (value.split('.') + [''])[:2]
101-
digits = digits[:9]
102-
return str(len(digits)) + number + digits
103-
else:
104-
length = int(fmt[1:])
105-
value = value[:length + 1]
106-
number, digits = (value.split('.') + [''])[:2]
107-
digits = digits[:9]
108-
return str(len(digits)) + (number + digits).rjust(length, '0')
153+
return _encode_decimal(ai, fmt, value)
109154
elif _type == 'date':
110-
if isinstance(value, (list, tuple)) and fmt in ('N6..12', 'N6[+N6]'):
111-
return '%s%s' % (
112-
_encode_value('N6', _type, value[0]),
113-
_encode_value('N6', _type, value[1]))
114-
elif isinstance(value, datetime.date):
115-
if fmt in ('N6', 'N6..12', 'N6[+N6]'):
116-
return value.strftime('%y%m%d')
117-
elif fmt == 'N10':
118-
return value.strftime('%y%m%d%H%M')
119-
elif fmt in ('N6+N..4', 'N6[+N..4]', 'N6[+N4]'):
120-
value = value.strftime('%y%m%d%H%M')
121-
if value.endswith('00'):
122-
value = value[:-2]
123-
if value.endswith('00'):
124-
value = value[:-2]
125-
return value
126-
elif fmt in ('N8+N..4', 'N8[+N..4]'):
127-
value = value.strftime('%y%m%d%H%M%S')
128-
if value.endswith('00'):
129-
value = value[:-2]
130-
if value.endswith('00'):
131-
value = value[:-2]
132-
return value
133-
else: # pragma: no cover (all formats should be covered)
134-
raise ValueError('unsupported format: %s' % fmt)
135-
return str(value)
136-
137-
138-
def _max_length(fmt: str, _type: str) -> int:
139-
"""Determine the maximum length based on the format ad type."""
140-
length = sum(
155+
return ai, _encode_date(fmt, value)
156+
else: # str or int types
157+
return ai, str(value)
158+
159+
160+
def _max_length(fmt: str) -> int:
161+
"""Determine the maximum length based on the format."""
162+
return sum(
141163
int(re.match(r'^[NXY][0-9]*?[.]*([0-9]+)[\[\]]?$', x).group(1)) # type: ignore[misc, union-attr]
142164
for x in fmt.split('+')
143165
)
144-
if _type == 'decimal':
145-
length += 1
146-
return length
147166

148167

149168
def _pad_value(fmt: str, _type: str, value: str) -> str:
150169
"""Pad the value to the maximum length for the format."""
151170
if _type in ('decimal', 'int'):
152-
return value.rjust(_max_length(fmt, _type), '0')
153-
return value.ljust(_max_length(fmt, _type))
171+
return value.rjust(_max_length(fmt), '0')
172+
else:
173+
return value.ljust(_max_length(fmt))
174+
175+
176+
def _decode_decimal(ai: str, fmt: str, value: str) -> decimal.Decimal | tuple[str, decimal.Decimal]:
177+
"""Decode the specified decimal value given the fmt."""
178+
if fmt.startswith('N3+'):
179+
# If the number consists of two parts, it is assumed that the decimal
180+
# from the AI applies to the second part
181+
return (value[:3], _decode_decimal(ai, fmt[3:], value[3:])) # type: ignore[return-value]
182+
decimals = int(ai[-1])
183+
if decimals:
184+
value = value[:-decimals] + '.' + value[-decimals:]
185+
return decimal.Decimal(value)
186+
187+
188+
def _decode_date(fmt: str, value: str) -> datetime.date | datetime.datetime | tuple[datetime.date, datetime.date]:
189+
"""Decode the specified date value given the fmt."""
190+
if len(value) == 6:
191+
if value[4:] == '00':
192+
# When day == '00', it must be interpreted as last day of month
193+
date = datetime.datetime.strptime(value[:4], '%y%m')
194+
if date.month == 12:
195+
date = date.replace(day=31)
196+
else:
197+
date = date.replace(month=date.month + 1, day=1) - datetime.timedelta(days=1)
198+
return date.date()
199+
else:
200+
return datetime.datetime.strptime(value, '%y%m%d').date()
201+
elif len(value) == 12 and fmt in ('N12', 'N6..12', 'N6[+N6]'):
202+
return (_decode_date('N6', value[:6]), _decode_date('N6', value[6:])) # type: ignore[return-value]
203+
else:
204+
# Other lengths are interpreted as variable-length datetime values
205+
return datetime.datetime.strptime(value, '%y%m%d%H%M%S'[:len(value)])
154206

155207

156-
def _decode_value(fmt: str, _type: str, value: str) -> Any:
208+
def _decode_value(ai: str, fmt: str, _type: str, value: str) -> Any:
157209
"""Decode the specified value given the fmt and type."""
158210
if _type == 'decimal':
159-
if fmt.startswith('N3+'):
160-
return (value[1:4], _decode_value(fmt[3:], _type, value[0] + value[4:]))
161-
digits = int(value[0])
162-
value = value[1:]
163-
if digits:
164-
value = value[:-digits] + '.' + value[-digits:]
165-
return decimal.Decimal(value)
211+
return _decode_decimal(ai, fmt, value)
166212
elif _type == 'date':
167-
if len(value) == 6:
168-
if value[4:] == '00':
169-
# When day == '00', it must be interpreted as last day of month
170-
date = datetime.datetime.strptime(value[:4], '%y%m')
171-
if date.month == 12:
172-
date = date.replace(day=31)
173-
else:
174-
date = date.replace(month=date.month + 1, day=1) - datetime.timedelta(days=1)
175-
return date.date()
176-
else:
177-
return datetime.datetime.strptime(value, '%y%m%d').date()
178-
elif len(value) == 12 and fmt in ('N12', 'N6..12', 'N6[+N6]'):
179-
return (_decode_value('N6', _type, value[:6]), _decode_value('N6', _type, value[6:]))
180-
else:
181-
# other lengths are interpreted as variable-length datetime values
182-
return datetime.datetime.strptime(value, '%y%m%d%H%M%S'[:len(value)])
213+
return _decode_date(fmt, value)
183214
elif _type == 'int':
184215
return int(value)
185-
return value.strip()
216+
else: # str
217+
return value.strip()
186218

187219

188220
def info(number: str, separator: str = '') -> dict[str, Any]:
@@ -208,7 +240,7 @@ def info(number: str, separator: str = '') -> dict[str, Any]:
208240
raise InvalidComponent()
209241
number = number[len(ai):]
210242
# figure out the value part
211-
value = number[:_max_length(info['format'], info['type'])]
243+
value = number[:_max_length(info['format'])]
212244
if separator and info.get('fnc1'):
213245
idx = number.find(separator)
214246
if idx > 0:
@@ -219,7 +251,7 @@ def info(number: str, separator: str = '') -> dict[str, Any]:
219251
mod = __import__(_ai_validators[ai], globals(), locals(), ['validate'])
220252
mod.validate(value)
221253
# convert the number
222-
data[ai] = _decode_value(info['format'], info['type'], value)
254+
data[ai] = _decode_value(ai, info['format'], info['type'], value)
223255
# skip separator
224256
if separator and number.startswith(separator):
225257
number = number[len(separator):]
@@ -253,12 +285,12 @@ def encode(data: Mapping[str, object], separator: str = '', parentheses: bool =
253285
if ai in _ai_validators:
254286
mod = __import__(_ai_validators[ai], globals(), locals(), ['validate'])
255287
mod.validate(value)
256-
value = _encode_value(info['format'], info['type'], value)
288+
ai, value = _encode_value(ai, info['format'], info['type'], value)
257289
# store variable-sized values separate from fixed-size values
258-
if info.get('fnc1'):
259-
variable_values.append((ai_fmt % ai, info['format'], info['type'], value))
260-
else:
290+
if not info.get('fnc1'):
261291
fixed_values.append(ai_fmt % ai + value)
292+
else:
293+
variable_values.append((ai_fmt % ai, info['format'], info['type'], value))
262294
# we need the separator for all but the last variable-sized value
263295
# (or pad values if we don't have a separator)
264296
return ''.join(

0 commit comments

Comments
 (0)