diff --git a/incl/GS1Parser.php b/incl/GS1Parser.php new file mode 100644 index 0000000..3911ca4 --- /dev/null +++ b/incl/GS1Parser.php @@ -0,0 +1,127 @@ +barcode = trim($barcode); + $this->parse(); + } + + private function parse() { + $code = $this->barcode; + + // 1. Remove symbology identifier if present (e.g. ]d2, ]D2, ]e0) + $code = preg_replace('/^\][a-zA-Z0-9]{2}/', '', $code); + + // 2. Normalize Human-Readable input by removing AI parentheses if present: "(01)...(17)..." -> "01...17..." + if (strpos($code, '(') !== false && strpos($code, ')') !== false) { + $code = str_replace(['(', ')'], '', $code); + } + + $offset = 0; + $length = strlen($code); + + while ($offset < $length) { + // Skip any leading Group Separator (ASCII 29) before reading the next AI + if (ord($code[$offset]) === 29) { + $offset++; + continue; + } + + $ai2 = substr($code, $offset, 2); + + if ($ai2 === '01') { + // GTIN: 14 digits fixed length + $this->gtin = substr($code, $offset + 2, 14); + $offset += 16; + } elseif ($ai2 === '17') { + // Expiration: 6 digits YYMMDD fixed length + $rawDate = substr($code, $offset + 2, 6); + $this->expirationDate = $this->parseDate($rawDate); + $offset += 8; + } elseif ($ai2 === '10') { + // Batch/Lot: Variable length (up to 20 chars) + $offset += 2; + $end = $this->findNextSeparator($code, $offset); + $this->batch = substr($code, $offset, $end - $offset); + $offset = $end; + } elseif ($ai2 === '21') { + // Serial: Variable length (up to 20 chars) + $offset += 2; + $end = $this->findNextSeparator($code, $offset); + $this->serial = substr($code, $offset, $end - $offset); + $offset = $end; + } else { + // Stop parsing if an unknown AI is encountered to prevent infinite loops + break; + } + } + } + + private function findNextSeparator($code, $offset) { + // Look for ASCII 29 (GS) + $pos = strpos($code, chr(29), $offset); + if ($pos !== false) { + return $pos; // Returns index of GS separator so the caller extracts data up to it + } + return strlen($code); + } + + private function parseDate($yymmdd) { + if (strlen($yymmdd) !== 6 || !is_numeric($yymmdd)) { + return null; + } + + $yy = intval(substr($yymmdd, 0, 2)); + $mm = intval(substr($yymmdd, 2, 2)); + $dd = intval(substr($yymmdd, 4, 2)); + + if ($mm < 1 || $mm > 12) { + return null; + } + + // GS1 Sliding Window Century Calculation: + // Rolling window: -51 years to +48 years relative to current year + $currentYear = intval(date('Y')); + $currentCentury = intval(floor($currentYear / 100) * 100); + $fullYear = $currentCentury + $yy; + + if ($fullYear - $currentYear > 48) { + $fullYear -= 100; + } elseif ($fullYear - $currentYear < -51) { + $fullYear += 100; + } + + // GS1 Date logic: DD = 00 means last day of specified month + if ($dd === 0) { + $dd = intval(date('t', strtotime(sprintf('%04d-%02d-01', $fullYear, $mm)))); + } + + return sprintf("%04d-%02d-%02d", $fullYear, $mm, $dd); + } + + public function getGtin() { + return $this->gtin; + } + + public function getExpirationDate() { + return $this->expirationDate; + } + + public function getBatch() { + return $this->batch; + } + + public function getSerial() { + return $this->serial; + } + + public function isValid() { + return !empty($this->gtin) && strlen($this->gtin) === 14; + } +} diff --git a/incl/api.inc.php b/incl/api.inc.php index 37c51a4..d30b5d0 100755 --- a/incl/api.inc.php +++ b/incl/api.inc.php @@ -356,7 +356,16 @@ public static function purchaseProduct(int $id, float $amount, ?string $bestbefo else $daysBestBefore = self::getDefaultBestBeforeDays($id); } - $data['best_before_date'] = self::formatBestBeforeDays($daysBestBefore); + + // Check if $daysBestBefore is a date string (YYYY-MM-DD) + if (preg_match("/^\d{4}-\d{2}-\d{2}$/", (string)$daysBestBefore)) { + $data['best_before_date'] = $daysBestBefore; + // We set this to a non-zero value to indicate success later + $daysBestBefore = 1; + } else { + $data['best_before_date'] = self::formatBestBeforeDays((int)$daysBestBefore); + } + $data_json = json_encode($data); $url = API_STOCK . "/" . $id . "/add"; @@ -586,11 +595,20 @@ public static function getProductByBarcode(string $barcode, bool $ignoreCache = return self::getProductInfo(checkIfNumeric($id)); } $allBarcodes = self::getAllBarcodes($ignoreCache); - if (!isset($allBarcodes[$barcode])) { - return null; - } else { - return self::getProductInfo($allBarcodes[$barcode]["id"]); - } + // Look for the raw scanned barcode (e.g., full 14-digit GTIN) + if (isset($allBarcodes[$barcode])) { + return self::getProductInfo($allBarcodes[$barcode]["id"]); + } + + // Fallback check: If not found, strip leading '0' padding from GTIN-14 and retry (e.g., convert to EAN-13) + if (strlen($barcode) === 14 && $barcode[0] === '0') { + $unpaddedBarcode = ltrim($barcode, '0'); + if (!empty($unpaddedBarcode) && isset($allBarcodes[$unpaddedBarcode])) { + return self::getProductInfo($allBarcodes[$unpaddedBarcode]["id"]); + } + } + + return null; } private static function getProductIdFromGrocyCode(string $barcode): ?int { diff --git a/incl/db.inc.php b/incl/db.inc.php index 34e5211..4b18687 100755 --- a/incl/db.inc.php +++ b/incl/db.inc.php @@ -107,6 +107,7 @@ class DatabaseConnection { "LOOKUP_USE_UPC_DATABASE" => "0", "LOOKUP_USE_OPEN_GTIN_DATABASE" => "0", "LOOKUP_USE_DISCOGS" => "0", + "GS1_PARSING_ENABLED" => "1", "LOOKUP_USE_BBUDDY_SERVER" => "0", "LOOKUP_UPC_DATABASE_KEY" => null, "LOOKUP_OPENGTIN_KEY" => null, @@ -449,9 +450,9 @@ public function setQuantityToUnknownBarcode(string $barcode, float $amount): voi * @param array|null $productname * @return void */ + public function insertUnrecognizedBarcode(string $barcode, float $amount = 1, ?string $bestBeforeInDays = null, ?string $price = null, ?array $productname = null): void { - if ($bestBeforeInDays == null) - $bestBeforeInDays = "NULL"; + $bestBeforeInDays = ($bestBeforeInDays === null) ? "NULL" : "'" . trim($bestBeforeInDays, "'") . "'"; if ($productname == null) { $name = "N/A"; @@ -473,8 +474,7 @@ public function insertUnrecognizedBarcode(string $barcode, float $amount = 1, ?s * @param null|string $price */ public function insertActionRequiredBarcode(string $barcode, ?string $bestBeforeInDays = null, ?string $price = null): void { - if ($bestBeforeInDays == null) - $bestBeforeInDays = "NULL"; + $bestBeforeInDays = ($bestBeforeInDays === null) ? "NULL" : "'" . trim($bestBeforeInDays, "'") . "'"; $this->db->exec("INSERT INTO Barcodes(barcode, name, amount, possibleMatch, requireWeight, bestBeforeInDays, price) VALUES('$barcode', 'N/A', 1, 0, 1, $bestBeforeInDays, '$price')"); diff --git a/incl/processing.inc.php b/incl/processing.inc.php index a04e67f..ae9fbb3 100755 --- a/incl/processing.inc.php +++ b/incl/processing.inc.php @@ -36,6 +36,26 @@ function processNewBarcode(string $barcodeInput, ?string $bestBeforeInDays = nul $config = BBConfig::getInstance(); $barcode = strtoupper($barcodeInput); + + // Check if code has GS1 DataMatrix identifier, human-readable brackets, or GS1 control char (ASCII 29) + if ($config["GS1_PARSING_ENABLED"] && ( + (strpos($barcodeInput, ']d2') === 0 || strpos($barcodeInput, ']D2') === 0) + || (strpos($barcodeInput, '(01)') === 0) + || (strpos($barcodeInput, '01') === 0 && strpos($barcodeInput, chr(29)) !== false) + || (strpos($barcodeInput, '01') === 0 && strlen($barcodeInput) >= 16) + ) { + // Try parsing as GS1 + require_once __DIR__ . "/GS1Parser.php"; + $parser = new GS1Parser($barcodeInput); + if ($parser->isValid()) { + $barcode = $parser->getGtin(); + $expDate = $parser->getExpirationDate(); + if ($expDate != null) { + $bestBeforeInDays = $expDate; + } + } + } + if ($barcode == $config["BARCODE_C"]) { $db->setTransactionState(STATE_CONSUME); return createLogModeChange(STATE_CONSUME); diff --git a/menu/settings.php b/menu/settings.php index 67a408d..cf8d9ba 100755 --- a/menu/settings.php +++ b/menu/settings.php @@ -105,6 +105,7 @@ function getHtmlSettingsGeneral(): string { $html->addCheckbox("USE_GENERIC_NAME", "Use generic names for lookup", $config["USE_GENERIC_NAME"], false, false); $html->addCheckbox("SHOW_STOCK_ON_SCAN", "Show stock amount on scan", $config["SHOW_STOCK_ON_SCAN"], false, false); $html->addCheckbox("SAVE_BARCODE_NAME", "Save name from lookup to barcode", $config["SAVE_BARCODE_NAME"], false, false); + $html->addCheckbox("GS1_PARSING_ENABLED", "Enable GS1/Datamatrix parsing", $config["GS1_PARSING_ENABLED"], false, false); $html->addCheckbox("MORE_VERBOSE", "More verbose logs", $config["MORE_VERBOSE"], false, false); $html->addLineBreak(2); $html->addHtml('Hint: You can find picture files of the default barcodes in the "example" folder or online');