Bỏ qua để đến nội dung

Grocery / supermarket POS (weighted barcodes, tax, EBT)

Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.

This page describes a supermarket-style product and checkout layer: weighted internal barcodes, configurable parsing rules, tax exemption, and EBT (SNAP) eligibility. Shared parsing helpers live in @indochina/shared under lib/retail-grocery.

TypeMeaning
NORMALFixed barcode; quantity from cashier entry or default 1.
WEIGHTSold by weight; quantity often comes from scale label (embedded) or manual entry.
PRICE_EMBEDDEDBarcode encodes total sale amount for the line (random weight / price).
QTY_EMBEDDEDBarcode encodes packaged quantity (e.g. 6-pack).

Use a single TypeScript const object (already in shared) as the source of truth for APIs and forms.

Custom barcode = store-internal 13-digit EAN-13: prefix(2) + PLU(4) + separator check(1) + value(5) + GS1 check(1)` (13 digits, indices 0–12).

Index (0-based)RoleIncluded in PLU / value parse?
0–1Prefix 02 (price) or 22 (weight)No
2–5PLU (4 digits)PLU only
6Separator check (GS1 on prefix + PLU(4))No — same algorithm family as index 12
7–11Embedded price or weight (5 digits)Value only
12GS1 check on digits 0–11No
PrefixVariantitem_typeSeparator check (index 6)Variable field (7–11)
02Price-embeddedPRICE_EMBEDDED, PER_UNIT (shelf)GS1 on 02 + PLU(4)Cents in USD (÷ 100 → dollars, max $999.99)
22Weight-embeddedWEIGHTGS1 on 22 + PLU(4)Thousandths of lb (÷ 1000, max 99.999 lb)

PLU at indices 2–5 (four digits). When the catalog PLU has more than four digits, encoding uses the last four for 02 and the first four for 22 (scale legacy). POS scan parse is positional for PLU/value; some deli scales use a different digit at index 6 than in-app GS1 encode (lenient parse). Helpers: encodeCatalogCustomShelfEan13, encodeCatalogCustomEan13, internalCustomPluSeparatorCheckOk, isInternalCustomEan13Digits, getInternalCustomEan13Variant, parseInternalCustomEan13 in @indochina/shared (internal-custom-ean13.ts). Catalog Generate barcode and POS workbench generation call the same encode helpers via apps/vendor-web/src/lib/generate-catalog-variant-barcode.ts.

Example 0212342000109 (custom price): 02 + PLU 1234 + separator check 2 (GS1 on 021234) + 00010$0.10 + GS1 check 9. For PLU 1234 at $1.00, encode yields 0212342001007. Scale label 0221376000102 still parses as PLU 2137 / $0.10 when index 6 is 6 (not our GS1 5 on 022137).

Layout (EAN-13, digits only): 22 + PLU (4) + separator check (1) + weight (5) + GS1 check (1).

Example scan: 2210001008259

SegmentDigitsMeaning
Prefix22Internal weighted class (configurable per chain).
PLU1000Four-digit PLU on the label (catalog 10001 → leading four).
Separator check1GS1 check on 22 + 1000 (index 6; not part of PLU/weight).
Weight00825Integer weight field; scale by 10^-decimal_places (default 30.825 lb).
GS1 check9Check digit over all 12 preceding digits.

Encoding new internal weighted labels (catalog + POS workbench)

Section titled “Encoding new internal weighted labels (catalog + POS workbench)”

Generation uses the same slice layout as parsing (defaultInternalWeightedEan13Rule). Implementations live in @indochina/shared:

HelperRole
encodeCatalogCustomShelfEan13(itemType, plu)Shelf label: 22 + PLU + 00000 (weight) or 02 + PLU + 00000 ($0.00) from item_type.
encodeCatalogCustomEan13(itemType, plu, { weightLb | priceDollars })Variable label on the same layout (workbench / scale).
encodeInternalWeightedShelfEan13(plu) / encodeInternalPriceEmbeddedShelfEan13(plu)Low-level shelf encode per prefix (used by the helpers above).
parseInternalWeightedEan13 / parseWeightLbFromInternalWeightedEan13Decode scans for PLU + lb quantity.
isInternalWeightedEan13Digits(digits)Prefer this over hand-written length === 13 && startsWith('22') — it delegates to the rule matcher (exact length, slices, types).

Vendor UI wraps shelf + weighted encode with a random valid EAN-13 fallback where legacy flows expected a non-null string (apps/vendor-web/src/lib/generate-catalog-variant-barcode.ts: generateCatalogVariantBarcode for new variant rows, encodeInternalWeightedEan13Lb with fallback for the barcode workbench).

  1. Normalize scan (digits-only for GS1).
  2. Run ordered rule list — first match wins (parseRetailBarcode in shared).
  3. Resolve catalog row by PLU (and store_id in multi-store POS).
  4. extended_subtotal = scaled_weight × unit_price (after unit conversion if needed).
  5. Apply discounts on eligible bases (policy-specific).
  6. Apply sales tax only on taxable, non-exempt bases (see Tax).
  7. Track EBT-eligible subtotal separately for tender limits (see EBT).

Store weight_unit on the product or on the barcode rule. Conversion factors should be centralized (e.g. lb → kg) so scale labels and back-office inventory agree.

Existing Laravel-backed items rows should gain new nullable columns (use project fp(...) naming when adding columns to legacy tables). Suggested model:

FieldTypeNotes
idPKExisting.
skustringExisting.
pluvarchar nullablePrimary key for scale labels + weighted lookup.
namestringExisting.
barcodestringShelf / case barcode; may differ from scale label.
item_typeenum stringNORMAL | WEIGHT | … (POS scan / qty resolution; not the item_types join labels)
unitstringea, lb, kg, …
unit_pricedecimalPrice per unit.
barcode_rule_idFK nullablePoints at store/chain rule row when not using defaults.
is_tax_exemptboolWhen true, no sales-tax merchandise base on the line (includes legacy rows that had is_taxable = 0 before that column was removed).
is_ebt_eligibleboolSNAP-eligible food flag.

Tax vs scan mode: item_type only affects how quantity is derived from barcodes (by weight, embedded price, etc.). Tax treatment is a separate catalog flag: use is_tax_exempt for zero sales-tax base.

Table pos_barcode_rules (or JSON blob on stores for MVP), keyed by store_id + sort_order:

ColumnPurpose
prefixLeading literal match.
product_code_start, product_code_length0-based slice into normalized barcode.
value_start, value_lengthVariable segment (weight / price / qty).
decimal_placesDivide integer value by 10^decimal_places.
value_typeWEIGHT | PRICE | QUANTITY.
weight_unitlb | kg when value_type = WEIGHT.

Shared type: RetailBarcodeRule — see parseRetailBarcode and defaultInternalWeightedEan13Rule.

  1. Per line: taxable_base = extended_subtotal only if !is_tax_exempt (see groceryTaxableBase in shared).
  2. Rate: resolve from store jurisdiction (zip / state) — not hardcoded in the parser.
  3. Prepared food: model as category flags or tax codes on the item; hot food often taxable even when raw ingredients are exempt (state-specific).
  1. Per line: if is_ebt_eligible, accumulate to EBT subtotal; else non-EBT (splitEbtEligibleSubtotal).
  2. Tender: capEbtTenderAmount(ebtSubtotal, requestedEbt) — never allocate EBT above eligible food subtotal.
  3. Split checkout: remainder after EBT must use cash, debit, credit, etc.
  4. Compliance: alcohol, tobacco, hot prepared foods, and general merchandise are typically ineligible — enforce with catalog flags, not cashier memory.
  • Decimal quantity: order lines and stock movements must use decimal (or integer smallest weight increment, e.g. milligrams) — migrating from integer-only quantity is a breaking contract change across POS DTOs and order_details.
  • Shrink / waste: post separate adjustment transactions so retail books stay aligned with scale sales.

Operator workflow: select PLU on scale → weigh → print EAN label → POS scan. No wire protocol is required in v1; optional future OPOS / JavaPOS or vendor SDK integration can push weight directly for stores without labels.

  • Multiple rules same prefix: keep deterministic sort order; first match wins.
  • UPC-A vs EAN-13: normalize to 13 digits with lead zero for GS1 algorithms.
  • Invalid check digit: reject or warn; do not silently change price.
  • Zero weight field: reject line or prompt manual weight.
  • Price-embedded totals: may bypass unit price × qty — reconcile with anti-fraud limits (max % off PLU).
  • Split packages: QTY_EMBEDDED may encode fractional cases — same decimal pipeline as weight.
  • Shipped: shared lib/retail-grocery — custom EAN-13 (02 / 22), parseRetailBarcode, internal-custom-ean13, internal-weighted-ean13, internal-price-embedded-ean13, GS1 check helpers, tax base + EBT split helpers, unit tests.
  • Vendor: generate-catalog-variant-barcode uses shared shelf encode for item_type = WEIGHT; POS weighted scan resolution uses isInternalWeightedEan13Digits + shared parsers (apps/vendor-web/src/lib/pos-weighted-barcode-scan.ts). Barcode workbench embeds weight via shared encode with vendor fallback (apps/vendor-web/src/app/point-of-sale/barcode-page.tsx).
  • Planned: DB migrations on items, store-scoped ordered rule lists beyond the default internal weighted rule, payment tender types for EBT (where not already wired).
  • Keep rules in data, not in cashier training.
  • Single parser in shared for web POS, desktop bridge, and server validation.
  • Audit trail for overridden tax/EBT flags (manager PIN).
  • Performance: pre-index catalog by plu + store_id; cache active rule list per store at shift open.