///|
/// A parsed GS1 Application Identifier element.
pub struct Gs1Element {
ai : String
data : String
title : String
fixed_length : Bool
} derive(Eq, Debug)
///|
priv struct Gs1Spec {
ai : String
title : String
min_length : Int
max_length : Int
numeric : Bool
}
///|
let gs1_specs : Array[Gs1Spec] = [
{ ai: "00", title: "SSCC", min_length: 18, max_length: 18, numeric: true },
{ ai: "01", title: "GTIN", min_length: 14, max_length: 14, numeric: true },
{ ai: "02", title: "CONTENT", min_length: 14, max_length: 14, numeric: true },
{
ai: "10",
title: "BATCH/LOT",
min_length: 1,
max_length: 20,
numeric: false,
},
{ ai: "11", title: "PROD DATE", min_length: 6, max_length: 6, numeric: true },
{ ai: "12", title: "DUE DATE", min_length: 6, max_length: 6, numeric: true },
{ ai: "13", title: "PACK DATE", min_length: 6, max_length: 6, numeric: true },
{
ai: "15",
title: "BEST BEFORE",
min_length: 6,
max_length: 6,
numeric: true,
},
{ ai: "16", title: "SELL BY", min_length: 6, max_length: 6, numeric: true },
{ ai: "17", title: "EXPIRY", min_length: 6, max_length: 6, numeric: true },
{ ai: "20", title: "VARIANT", min_length: 2, max_length: 2, numeric: true },
{ ai: "21", title: "SERIAL", min_length: 1, max_length: 20, numeric: false },
{ ai: "22", title: "CPV", min_length: 1, max_length: 20, numeric: false },
{ ai: "30", title: "COUNT", min_length: 1, max_length: 8, numeric: true },
{
ai: "37",
title: "COUNT OF TRADE ITEMS",
min_length: 1,
max_length: 8,
numeric: true,
},
{
ai: "240",
title: "ADDITIONAL ID",
min_length: 1,
max_length: 30,
numeric: false,
},
{
ai: "241",
title: "CUSTOMER PART",
min_length: 1,
max_length: 30,
numeric: false,
},
{
ai: "242",
title: "MTO VARIANT",
min_length: 1,
max_length: 6,
numeric: false,
},
{
ai: "250",
title: "SECONDARY SERIAL",
min_length: 1,
max_length: 30,
numeric: false,
},
{
ai: "251",
title: "REFERENCE TO SOURCE",
min_length: 1,
max_length: 30,
numeric: false,
},
{
ai: "400",
title: "ORDER NUMBER",
min_length: 1,
max_length: 30,
numeric: false,
},
{ ai: "401", title: "GINC", min_length: 1, max_length: 30, numeric: false },
{ ai: "402", title: "GSIN", min_length: 17, max_length: 17, numeric: true },
{
ai: "410",
title: "SHIP TO GLN",
min_length: 13,
max_length: 13,
numeric: true,
},
{
ai: "411",
title: "BILL TO GLN",
min_length: 13,
max_length: 13,
numeric: true,
},
{
ai: "412",
title: "PURCHASE FROM GLN",
min_length: 13,
max_length: 13,
numeric: true,
},
{
ai: "413",
title: "SHIP FOR GLN",
min_length: 13,
max_length: 13,
numeric: true,
},
{
ai: "414",
title: "PHYSICAL LOCATION GLN",
min_length: 13,
max_length: 13,
numeric: true,
},
{
ai: "420",
title: "SHIP TO POST",
min_length: 1,
max_length: 20,
numeric: false,
},
{
ai: "421",
title: "SHIP TO POST WITH COUNTRY",
min_length: 4,
max_length: 12,
numeric: false,
},
{
ai: "422",
title: "ORIGIN COUNTRY",
min_length: 3,
max_length: 3,
numeric: true,
},
{
ai: "7001",
title: "NATO STOCK NUMBER",
min_length: 13,
max_length: 13,
numeric: true,
},
{
ai: "7002",
title: "MEAT CUT",
min_length: 1,
max_length: 30,
numeric: false,
},
{
ai: "7003",
title: "EXPIRY WITH TIME",
min_length: 10,
max_length: 10,
numeric: true,
},
{
ai: "8001",
title: "DIMENSIONS",
min_length: 14,
max_length: 14,
numeric: true,
},
{
ai: "8002",
title: "CMT NUMBER",
min_length: 1,
max_length: 20,
numeric: false,
},
{ ai: "8003", title: "GRAI", min_length: 14, max_length: 30, numeric: false },
{ ai: "8004", title: "GIAI", min_length: 1, max_length: 30, numeric: false },
{
ai: "8005",
title: "PRICE PER UNIT",
min_length: 6,
max_length: 6,
numeric: true,
},
{ ai: "8006", title: "ITIP", min_length: 18, max_length: 18, numeric: true },
{ ai: "8010", title: "CPID", min_length: 1, max_length: 30, numeric: false },
{
ai: "8011",
title: "CPID SERIAL",
min_length: 1,
max_length: 12,
numeric: true,
},
{
ai: "8017",
title: "GSRN PROVIDER",
min_length: 18,
max_length: 18,
numeric: true,
},
{
ai: "8018",
title: "GSRN RECIPIENT",
min_length: 18,
max_length: 18,
numeric: true,
},
{
ai: "8020",
title: "PAYMENT SLIP",
min_length: 1,
max_length: 25,
numeric: false,
},
]
///|
fn gs1_spec(ai : String) -> Gs1Spec? {
for spec in gs1_specs {
if spec.ai == ai {
return Some(spec)
}
}
// Measurement AIs 310n-369n carry six numeric digits. The final AI digit
// specifies the decimal position.
if ai.length() == 4 && ai >= "3100" && ai <= "3699" && is_all_digits(ai) {
return Some({
ai,
title: "MEASUREMENT",
min_length: 6,
max_length: 6,
numeric: true,
})
}
// Monetary amount AIs 390n-395n are variable-length numeric fields.
if ai.length() == 4 && ai >= "3900" && ai <= "3959" && is_all_digits(ai) {
return Some({
ai,
title: "AMOUNT",
min_length: 1,
max_length: 15,
numeric: true,
})
}
None
}
///|
fn validate_gs1_value(spec : Gs1Spec, value : String) -> Result[Unit, String] {
if value.length() < spec.min_length || value.length() > spec.max_length {
return Err(
"AI \{spec.ai} requires \{spec.min_length}-\{spec.max_length} characters",
)
}
if spec.numeric && !is_all_digits(value) {
return Err("AI \{spec.ai} requires numeric data")
}
let bytes = @utf8.encode(value)
for byte in bytes {
if byte < 32 || byte > 126 || byte == b'(' || byte == b')' {
return Err("AI \{spec.ai} contains unsupported data")
}
}
if spec.ai == "00" && !mod10_verify(value, 3, 1) {
return Err("AI 00 SSCC check digit mismatch")
}
if (spec.ai == "01" || spec.ai == "02") && !mod10_verify(value, 3, 1) {
return Err("AI \{spec.ai} GTIN check digit mismatch")
}
Ok(())
}
///|
fn find_closing_parenthesis(input : String, start : Int) -> Int? {
for i = start; i < input.length(); i = i + 1 {
if input[i] == ')' {
return Some(i)
}
}
None
}
///|
/// Parse human-readable GS1 element syntax such as
/// `(01)09506000134352(17)270101(10)LOT42`.
pub fn parse_gs1(input : String) -> Result[Array[Gs1Element], String] {
let elements : Array[Gs1Element] = []
let mut position = 0
while position < input.length() {
if input[position] != '(' {
return Err("expected '(' at position \{position}")
}
let close = match find_closing_parenthesis(input, position + 1) {
Some(index) => index
None => return Err("missing ')' for AI at position \{position}")
}
let ai = input.sub(start=position + 1, end=close).to_owned()
let spec = match gs1_spec(ai) {
Some(value) => value
None => return Err("unsupported GS1 application identifier: \{ai}")
}
let data_start = close + 1
let mut data_end = input.length()
for i = data_start; i < input.length(); i = i + 1 {
if input[i] == '(' {
data_end = i
break
}
}
let data = input.sub(start=data_start, end=data_end).to_owned()
match validate_gs1_value(spec, data) {
Err(message) => return Err(message)
Ok(_) => ()
}
elements.push({
ai,
data,
title: spec.title,
fixed_length: spec.min_length == spec.max_length,
})
position = data_end
}
if elements.is_empty() {
Err("GS1 input must contain at least one element")
} else {
Ok(elements)
}
}
///|
/// Format parsed elements back to parenthesized human-readable GS1 syntax.
pub fn format_gs1(elements : Array[Gs1Element]) -> String {
let out = Buffer()
for element in elements {
out.write_string_utf8("(\{element.ai})\{element.data}")
}
@utf8.decode_lossy(out.to_bytes())
}
///|
/// Build a GS1-128 payload. Variable-length fields are separated by FNC1
/// (`\u{1d}`) unless they are the final field.
pub fn gs1_128_payload(elements : Array[Gs1Element]) -> Result[String, String] {
if elements.is_empty() {
return Err("GS1 payload must contain at least one element")
}
let out = Buffer()
for i = 0; i < elements.length(); i = i + 1 {
let element = elements[i]
let spec = match gs1_spec(element.ai) {
Some(value) => value
None =>
return Err("unsupported GS1 application identifier: \{element.ai}")
}
match validate_gs1_value(spec, element.data) {
Err(message) => return Err(message)
Ok(_) => ()
}
out.write_string_utf8(element.ai)
out.write_string_utf8(element.data)
if spec.min_length != spec.max_length && i + 1 < elements.length() {
out.write_byte(29)
}
}
Ok(@utf8.decode_lossy(out.to_bytes()))
}
///|
/// Return true when a six-digit GS1 date has a valid month/day shape.
/// Day `00` is accepted because GS1 uses it for an unspecified day.
pub fn validate_gs1_date(value : String) -> Bool {
if value.length() != 6 || !is_all_digits(value) {
return false
}
let bytes = @utf8.encode(value)
let month = (bytes[2] - b'0').to_int() * 10 + (bytes[3] - b'0').to_int()
let day = (bytes[4] - b'0').to_int() * 10 + (bytes[5] - b'0').to_int()
month >= 1 && month <= 12 && day >= 0 && day <= 31
}