feat(sku): canonical 14-digit timestamp SKUs so RFID/EPC tagging works (new items)

RecordGod generated 'RG'+random-hex SKUs, which the SCANGOD EPC codec rejects
(it requires 14 digits: SKU × 10^9 + release_id packed onto the UHF chip). New
inventory now gets a 14-digit product-added timestamp (YYYYMMDDHHMMSS) via
_fresh_sku(), which bumps a second on collision to stay unique against the sku
PRIMARY KEY. release_id stays its own column (the sku+space+release_id string is
only the physical chip encoding, which the codec already builds).

New items only — existing 'RG' SKUs are untouched (they keep working; they just
aren't RFID-taggable until re-SKU'd, a separate migration if ever wanted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-05 03:34:27 +10:00
parent 61601f1067
commit 00e08a86dd

View File

@ -1,5 +1,6 @@
import re import re
import secrets import secrets
from datetime import datetime, timedelta
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -547,9 +548,24 @@ class BulkIn(BaseModel):
value: float | int | str | None = None value: float | int | str | None = None
async def _fresh_sku(db):
"""Canonical SKU = 14-digit product-added timestamp (YYYYMMDDHHMMSS) — the format SCANGOD's EPC/RFID
codec requires (packs SKU 14 digits × 10^9 + release_id onto the UHF chip). Bumps a second on
collision so a same-second batch stays unique against the sku PRIMARY KEY.
ponytail: check-then-insert; a truly concurrent same-second add on another connection could still hit
the PK and be ON CONFLICT DO NOTHING'd. Fine for counter/admin stock entry; add retry-on-conflict if
bulk-import concurrency grows."""
base = datetime.now()
for i in range(120):
sku = (base + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")
if not (await db.execute(text("SELECT 1 FROM inventory WHERE sku = :s"), {"s": sku})).first():
return sku
return (base + timedelta(seconds=120)).strftime("%Y%m%d%H%M%S")
@router.post("/inventory/add") @router.post("/inventory/add")
async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)): async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)):
sku = (body.sku or "").strip() or ("RG" + secrets.token_hex(4).upper()) sku = (body.sku or "").strip() or await _fresh_sku(db)
title = body.title title = body.title
if body.release_id and not title: # pull the cached title if intaking by release if body.release_id and not title: # pull the cached title if intaking by release
r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"), r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"),
@ -629,7 +645,7 @@ async def inv_import(body: ImportIn, ident=Depends(require_token), db=Depends(ge
if not rid: if not rid:
not_found.append(ln) not_found.append(ln)
continue continue
sku = "RG" + secrets.token_hex(4).upper() sku = await _fresh_sku(db)
await db.execute(text(""" await db.execute(text("""
INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition, INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition,
crate_id, status, created_at, updated_at) crate_id, status, created_at, updated_at)