1d5f775d33
- 迁移 0007: 新增 product_barcode 表(一品多码),回填旧 product.gtin 为主码, 全局唯一索引保证「一码一品」,每品至多一个主码 - internal/gtin: GS1 GTIN-8/12/13/14 校验(校验位 + 拒收店内码/变量重量码/优惠券码) - 公开只读 API: 任一条码命中商品、详情返回 barcodes、搜索匹配条码 - adminstore: 商品详情含 barcodes;新增 AddBarcode/DeleteBarcode/SetPrimaryBarcode, 一码命中其他商品返回 ConflictError 供后台去重 待办(按用户要求暂停): 后台 handler 路由、投稿/审核多条码、前后端 UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
111 lines
2.7 KiB
Go
111 lines
2.7 KiB
Go
// Package gtin validates and normalizes GS1 trade item numbers (GTIN-8/12/13/14).
|
|
// Only globally-unique GS1 codes are accepted: store-internal / variable-weight /
|
|
// coupon codes (which are not globally unique) are rejected on purpose.
|
|
package gtin
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
)
|
|
|
|
// Validation errors.
|
|
var (
|
|
ErrEmpty = errors.New("条码不能为空")
|
|
ErrFormat = errors.New("条码必须为 8/12/13/14 位数字")
|
|
ErrCheck = errors.New("条码校验位不正确")
|
|
ErrRestricted = errors.New("店内码/变量重量码/优惠券码等非全球唯一码,不予收录")
|
|
)
|
|
|
|
// Normalize trims and validates a GTIN, returning the cleaned digit string.
|
|
// It enforces length, the GS1 mod-10 check digit, and rejects restricted
|
|
// (non-globally-unique) number ranges.
|
|
func Normalize(raw string) (string, error) {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" {
|
|
return "", ErrEmpty
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return "", ErrFormat
|
|
}
|
|
}
|
|
switch len(s) {
|
|
case 8, 12, 13, 14:
|
|
default:
|
|
return "", ErrFormat
|
|
}
|
|
if !validCheckDigit(s) {
|
|
return "", ErrCheck
|
|
}
|
|
if restricted(s) {
|
|
return "", ErrRestricted
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// InferType returns the conventional GTIN type label for a normalized code.
|
|
func InferType(s string) string {
|
|
switch len(s) {
|
|
case 8:
|
|
return "EAN8"
|
|
case 12:
|
|
return "UPC"
|
|
case 14:
|
|
return "GTIN14"
|
|
default:
|
|
return "EAN13"
|
|
}
|
|
}
|
|
|
|
// validCheckDigit verifies the trailing GS1 mod-10 check digit. The digit
|
|
// immediately left of the check digit carries weight 3, then weights alternate.
|
|
func validCheckDigit(s string) bool {
|
|
n := len(s)
|
|
sum := 0
|
|
for i := 0; i < n-1; i++ {
|
|
d := int(s[i] - '0')
|
|
if (n-1-i)%2 == 1 {
|
|
sum += d * 3
|
|
} else {
|
|
sum += d
|
|
}
|
|
}
|
|
check := (10 - (sum % 10)) % 10
|
|
return check == int(s[n-1]-'0')
|
|
}
|
|
|
|
// restricted reports whether a (length/check-digit valid) code falls in a
|
|
// number range reserved for non-globally-unique use.
|
|
func restricted(s string) bool {
|
|
switch len(s) {
|
|
case 13:
|
|
p2 := s[:2]
|
|
switch {
|
|
case s[0] == '2': // 20-29 restricted distribution / in-store
|
|
return true
|
|
case p2 == "02": // 020-029 variable-measure within a store
|
|
return true
|
|
case p2 == "04": // 040-049 restricted circulation within a company
|
|
return true
|
|
case p2 == "05": // 050-059 coupons
|
|
return true
|
|
case p2 == "98" || p2 == "99": // 980-989/99 coupons & refund receipts
|
|
return true
|
|
}
|
|
case 12: // UPC-A: leading number-system digit
|
|
switch s[0] {
|
|
case '2': // in-store / random weight
|
|
return true
|
|
case '4': // unrestricted in-store use
|
|
return true
|
|
case '5': // coupons
|
|
return true
|
|
}
|
|
case 8: // EAN-8: 0/2 prefixes reserved for in-store use
|
|
if s[0] == '0' || s[0] == '2' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|