diff --git a/data/tag-allocations/blocks.csv b/data/tag-allocations/blocks.csv new file mode 100644 index 0000000..ea52a2b --- /dev/null +++ b/data/tag-allocations/blocks.csv @@ -0,0 +1,5 @@ +block_id,first,last,ordered_qty,buffer_pct,tag_type,last_used,status +PO7299-L1,10000001,10002625,2500,5,Avery Miniweb,,issued +PO7299-L2,10002626,10005250,2500,5,Dogbone,,issued +PO7299-L3,10005251,10007875,2500,5,Avery Web,,issued +PO7299-L4,10007876,10012600,4500,5,Tageos EOS 261 U9 white PET,,issued diff --git a/data/tag-allocations/namespaces.csv b/data/tag-allocations/namespaces.csv new file mode 100644 index 0000000..be9d3f5 --- /dev/null +++ b/data/tag-allocations/namespaces.csv @@ -0,0 +1,4 @@ +code,status,note +000,standard,TrakRF standard pool +001,reserved,customer namespace (owner in private mapping) +0A5,reserved,documentation example on the tag numbering page; never assign diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 557c5f2..d530935 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -16,4 +16,6 @@ None of the integration surfaces below are documented yet for self-service. Earl ## What's documented today +**[Tag numbering and encoding](./tag-numbering)** — how TrakRF numbers the tags it supplies: the EPC layout, encoding instructions for print bureaus, label rules, and first-article checks. + For REST API integration (the primary customer-facing integration surface), start at **[Getting started → Using the API](/docs/getting-started/api)** and the **[interactive API reference](/api)**. diff --git a/docs/integrations/tag-allocations.md b/docs/integrations/tag-allocations.md new file mode 100644 index 0000000..d9f6a9f --- /dev/null +++ b/docs/integrations/tag-allocations.md @@ -0,0 +1,35 @@ +--- +# Generated by scripts/allocate-tags.mjs from data/tag-allocations/. Do not edit: +# pnpm lint fails if this page and the data disagree. +sidebar_position: 5 +title: Tag allocation register +description: Every block of TrakRF standard-pool tag numbers issued so far, and every namespace code assigned. +--- + +# Tag allocation register + +Every block of [standard-pool](./tag-numbering#how-the-numbers-are-issued) tag numbers TrakRF has issued, and every namespace code assigned. Numbers are drawn from one global counter, in order, and never reissued. + +## Standard-pool blocks + +The next block starts at **10012601**. + +| Block | First | Last | Ordered | Buffer | Tag type | Status | Last used | +| --------- | -------- | -------- | ------- | ------ | --------------------------- | ------ | --------- | +| PO7299-L1 | 10000001 | 10002625 | 2500 | 125 | Avery Miniweb | issued | — | +| PO7299-L2 | 10002626 | 10005250 | 2500 | 125 | Dogbone | issued | — | +| PO7299-L3 | 10005251 | 10007875 | 2500 | 125 | Avery Web | issued | — | +| PO7299-L4 | 10007876 | 10012600 | 4500 | 225 | Tageos EOS 261 U9 white PET | issued | — | + +- **Ordered** is the first part of the block; **Buffer** is the overrun allowance at its end. +- **Status**: `issued` (with the bureau), `closed` (last number used reported, the rest retired), or `void` (cancelled, never reissued). + +## Namespaces + +| Code | Status | Note | +| ----- | -------- | ------------------------------------------------------------- | +| `000` | standard | TrakRF standard pool | +| `001` | reserved | customer namespace (owner in private mapping) | +| `0A5` | reserved | documentation example on the tag numbering page; never assign | + +Customer namespaces control their own sequencing, so they have no blocks here. A code is never reassigned, even after a customer leaves. diff --git a/docs/integrations/tag-numbering.md b/docs/integrations/tag-numbering.md new file mode 100644 index 0000000..d3670ab --- /dev/null +++ b/docs/integrations/tag-numbering.md @@ -0,0 +1,197 @@ +--- +sidebar_position: 4 +title: Tag numbering and encoding +description: How TrakRF numbers the RFID tags it supplies — the 96-bit EPC layout, customer namespaces, encoding instructions for print bureaus, label rules, and acceptance checks. +--- + +# Tag numbering and encoding + +This page is the reference for how TrakRF numbers the RFID tags it supplies. It is written for +print and encoding bureaus, partners, and anyone receiving TrakRF-encoded tags. + +## Summary + +A tag numbered from TrakRF's standard pool carries one number, written two ways: + +- **On the label**: an 8-digit sequence number, for example `31570344`. +- **In the chip**: a 96-bit EPC made of a fixed 12-character prefix, four reserved zeros, and the + same 8 digits. + +| Printed on the label | Encoded in the chip (EPC, hex) | +| -------------------- | ------------------------------ | +| `31570344` | `355E4ACFD000000031570344` | + +The last 8 characters of the EPC are always the printed number. The sequence digits are written +into the EPC as decimal digits, one digit per hex character, so nothing after the prefix is ever +`A`–`F`. + +Customers with their own numbering get a [customer namespace](#customer-namespaces) instead. + +## EPC layout + +The EPC is 24 hex characters (96 bits). Positions count from the left, starting at 1. + +| Characters | Field | Value | +| ---------- | ------------------------ | --------------------------------------------------------------- | +| 1–2 | Header | `35` (GID-96) | +| 3–9 | General Manager Number | `5E4ACFD` (98872573 decimal) | +| 10–12 | Namespace code | `000` = TrakRF standard pool; `001`–`FFF` = customer namespaces | +| 13–16 | Reserved (standard pool) | `0000` | +| 17–24 | Sequence (standard pool) | 8 decimal digits, `10000001`–`99999999` | + +In GID-96 terms, characters 10–15 are the Object Class field and characters 16–24 are the serial +field. The namespace code occupies the top of the Object Class. + +Characters 1–12, `355E4ACFD000`, are the standard-pool prefix. A customer namespace replaces `000` +with its own code. + +### How the numbers are issued + +- Sequence numbers come from one global counter, starting at `10000001`. A number is never + reused. +- The standard pool (namespace `000`) covers TrakRF's own numbering for every customer, order, and + demo roll, with no customer code in the EPC. Customers with their own numbering use a + [customer namespace](#customer-namespaces). +- Each order is allocated a contiguous block: the ordered quantity plus an overrun buffer (5% by + default, adjusted to the bureau's stated tolerance). Blocks needn't start or end on round + numbers. Buffer numbers that go unused are retired, not reissued. +- Within a roll, tags run from the lowest number to the highest. +- The [tag allocation register](./tag-allocations) lists every block issued so far. + +### What a standard decoder shows + +The layout is a valid GID-96 encoding, so standard EPC decoders read it. For the example above +they show: + +```text +urn:epc:id:gid:98872573.0.827786052 +``` + +The serial a decoder shows (`827786052`) is not the printed number. GID-96 reads the last 9 hex +characters (`031570344`) as one binary value. The printed number is those same characters read +as decimal digits. Match tags to labels on the last 8 characters of the EPC, never on the +decoded serial. + +## Customer namespaces + +A customer namespace prefix is `355E4ACFD` plus a 3-character namespace code, `001`–`FFF`. The +customer controls characters 13–24 (48 bits) and can use any sequencing in them: hex or decimal, +starting from zero or one. + +For example, with the illustrative namespace code `0A5` (not a real customer) and a one-based hex +sequence: + +| EPC | Standard decoder shows | +| -------------------------- | ---------------------------------- | +| `355E4ACFD0A5000000000001` | `urn:epc:id:gid:98872573.675840.1` | +| `355E4ACFD0A5000000000002` | `urn:epc:id:gid:98872573.675840.2` | + +A standard decoder shows the Object Class as the namespace code × 4096 plus the value of the first +3 characters of the customer's tail, read as hex. That's expected, for example: + +```text +355E4ACFD0A5A00000000001 → urn:epc:id:gid:98872573.678400.1 +``` + +Match on the EPC characters, not the decoded fields. + +- **Customer-required rightmost characters**: when a customer's systems key on their last N + characters, those characters are encoded unchanged at the right. Any padding goes between the + prefix and their value, never inside it. +- **No all-zero prefixes by default.** Zero-padded EPCs collide with other vendors' tags and + clutter shared reader fields, so for a customer with its own numbering TrakRF proposes a + namespace. A zero-padded or other customer-mandated prefix is used only when the customer + explicitly requires it. Those tags fall outside both the standard pool and the namespaces. +- **Codes are never reassigned.** TrakRF records each namespace code in its own register and + doesn't reassign it, even after a customer leaves. + +## Encoding instructions for bureaus + +### All orders + +- Write the **EPC bank (MB01)**: a **96-bit EPC**, with the EPC length in the PC word set to + **6 words**. Leave the other PC bits at the chip default. +- **No lock and no access password.** +- **On an encode failure, re-encode the same value on the next good tag.** Never skip a number. +- **Return an encode log for every run**, with one row per encoded tag: EPC and TID, plus the + data-file row number where there is a data file. + +### Standard pool (namespace `000`) + +TrakRF supplies a data file with one row per tag. Encode from that file only, writing the +`epc_hex` value from each row. + +**Don't use a start-and-increment rule.** The sequence is stored as decimal digits, and encoder +software counts in binary, so it would produce `A`–`F` characters. Take each EPC from its row. + +The file columns are: + +```text +order,line,roll,tag_type,sequence,epc_hex,barcode,human_readable,overrun +``` + +Rows with `overrun=yes` are the buffer. Encode in file order and use buffer rows only to cover +overruns. Report the last number used back to TrakRF. + +### Customer namespace + +The order specifies either a data file, or a start value with a hex increment. + +## Label rules + +| Element | Content | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Standard pool: human-readable text | The 8-digit sequence | +| Standard pool: barcode | Code 128, holding the same 8 digits | +| Standard pool: QR code | Only when the order asks for one: the full 24-character EPC | +| Header text | Specified per order | +| Customer namespace order | Human-readable, barcode and QR content as specified per order — typically the customer's required characters, not the full EPC | + +## Acceptance checks + +### First article, before the full run + +1. The EPC decodes as GID-96 with General Manager Number `98872573`. +2. The last 8 characters of the EPC equal the printed number. For a customer namespace, the + customer's required rightmost characters equal the printed value. +3. The barcode scans to the same 8 digits. For a customer namespace, it scans to the customer's + printed value. + +Worked through for the standard-pool example: + +| Check | Result | +| ------------------ | ------------------------------------------------------------------ | +| Decodes as GID-96 | `355E4ACFD000000031570344` → `urn:epc:id:gid:98872573.0.827786052` | +| Last 8 = printed | `…31570344` = `31570344` | +| Barcode = 8 digits | Code 128 `31570344` | + +### After the run, from the encode log + +- No two tags in the run carry the same EPC. Across runs, the bureau doesn't need to check against + earlier orders: TrakRF's allocation register draws every standard-pool block once, so no number is + issued twice. +- The last number used is reported to TrakRF. + +## How TrakRF shows tag IDs + +TrakRF stores and matches on the full EPC. Today the app shows a TrakRF tag as its full EPC, for +example `355E4ACFD000000031570344`, and you find a tag by its full EPC — for instance by pasting +it into [Locate](/docs/app-tour/locate). The app trims leading zeros from tag values, which +doesn't change a TrakRF EPC because it starts with `3`. + +**Planned:** a per-organization prefix list, with `355E4ACFD000` as the default. When an EPC +starts with a listed prefix, the app removes the prefix and strips leading zeros for display, so a +standard-pool tag shows as its printed 8 digits. Searching by those 8 digits or by the full EPC +will find the same tag. A customer namespace prefix is added to that organization's list, so its +tags show as the customer's characters with leading zeros stripped. This page will be updated +when that ships. + +## Status and caveats + +- **`98872573` is a General Manager Number TrakRF assigned itself.** It was not issued by GS1. +- **The scheme is for closed-loop use.** Don't publish these IDs into open GS1 or EPCIS supply + chains. +- **A move to a RAIN CIN or GS1 prefix is planned.** The sequence pool stays global across any + prefix change, so an 8-digit number never exists under two prefixes. + +Questions about an order or a data file: [email support](mailto:support@trakrf.id). diff --git a/package.json b/package.json index 53a343f..9571fa4 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "docusaurus build", "serve": "docusaurus serve --host 0.0.0.0", "typecheck": "tsc", - "lint": "prettier --check . && node scripts/check-contributing.mjs", + "lint": "prettier --check . && node scripts/check-contributing.mjs && node scripts/check-tag-numbering.mjs && node scripts/check-tag-allocations.mjs", "lint:fix": "prettier --write .", "clear": "docusaurus clear", "swizzle": "docusaurus swizzle", diff --git a/scripts/allocate-tags.mjs b/scripts/allocate-tags.mjs new file mode 100644 index 0000000..eaae741 --- /dev/null +++ b/scripts/allocate-tags.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Issues TrakRF standard-pool tag numbers. Every allocation goes through this +// script and a pull request; nobody edits blocks.csv by hand to issue numbers. +// +// allocate --block-id PO1234-L1 --qty 2500 [--buffer 5] --tag-type "Avery Web" +// export --block-id PO1234-L1 [--out file.csv] per-tag bureau file +// close --block-id PO1234-L1 --last-used 10002590 +// page re-render the register page +// +// --data DIR works on another copy of the register (tests); the public page is +// only re-rendered for the real one. + +import { writeFile } from "node:fs/promises"; +import { + EXPORT_COLUMNS, + SEQ_MAX, + bufferFor, + defaultDataDir, + exportRows, + nextFirst, + readRegister, + toCsv, + validate, + writeBlocks, + writePage, +} from "./lib/tag-allocations.mjs"; + +const [command, ...rest] = process.argv.slice(2); +const opts = {}; +for (let i = 0; i < rest.length; i += 2) { + if (!rest[i].startsWith("--")) fail(`unexpected argument ${rest[i]}`); + opts[rest[i].slice(2)] = rest[i + 1]; +} +const dataDir = opts.data ?? defaultDataDir; + +function fail(message) { + console.error(`allocate-tags: ${message}`); + process.exit(1); +} +function need(name) { + if (opts[name] === undefined || opts[name] === "") + fail(`--${name} is required`); + return opts[name]; +} + +const register = await readRegister(dataDir); +const existing = validate(register); +if (existing.length) + fail(`register is inconsistent, fix it first:\n ${existing.join("\n ")}`); +const findBlock = (id) => + register.blocks.find((b) => b.block_id === id) ?? fail(`no block ${id}`); + +async function save() { + const errors = validate(register); + if (errors.length) + fail( + `refusing to write an inconsistent register:\n ${errors.join("\n ")}`, + ); + await writeBlocks(register.blocks, dataDir); + if (dataDir === defaultDataDir) await writePage(register); +} + +switch (command) { + case "allocate": { + const id = need("block-id"); + const qty = Number(need("qty")); + const pct = Number(opts.buffer ?? 5); + const tagType = need("tag-type"); + if (!Number.isInteger(qty) || qty < 1) + fail("--qty must be a positive integer"); + if (!Number.isInteger(pct) || pct < 0) + fail("--buffer must be a whole percentage"); + if (register.blocks.some((b) => b.block_id === id)) + fail(`block ${id} already exists`); + const first = nextFirst(register.blocks); + const last = first + qty + bufferFor(qty, pct) - 1; + if (last > SEQ_MAX) fail(`block would end at ${last}, past ${SEQ_MAX}`); + register.blocks.push({ + block_id: id, + first: String(first), + last: String(last), + ordered_qty: String(qty), + buffer_pct: String(pct), + tag_type: tagType, + last_used: "", + status: "issued", + }); + await save(); + console.log( + `${id}: ${first}–${last} (${qty} ordered + ${last - first + 1 - qty} buffer)`, + ); + break; + } + case "export": { + const csv = toCsv(EXPORT_COLUMNS, exportRows(findBlock(need("block-id")))); + if (opts.out) await writeFile(opts.out, csv); + else process.stdout.write(csv); + break; + } + case "close": { + const block = findBlock(need("block-id")); + const lastUsed = need("last-used"); + if (block.status !== "issued") + fail(`${block.block_id} is ${block.status}, not issued`); + if ( + !/^[0-9]{8}$/.test(lastUsed) || + +lastUsed < +block.first || + +lastUsed > +block.last + ) + fail(`--last-used must be within ${block.first}–${block.last}`); + block.last_used = lastUsed; + block.status = "closed"; + await save(); + console.log( + `${block.block_id}: closed at ${lastUsed}; ${+block.last - +lastUsed} numbers retired`, + ); + break; + } + case "page": + if (dataDir !== defaultDataDir) fail("page renders the real register only"); + await writePage(register); + console.log("docs/integrations/tag-allocations.md re-rendered"); + break; + default: + fail( + "usage: allocate | export | close | page (see the header of this script)", + ); +} diff --git a/scripts/check-tag-allocations.mjs b/scripts/check-tag-allocations.mjs new file mode 100644 index 0000000..03a7a6e --- /dev/null +++ b/scripts/check-tag-allocations.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Lint gate for the tag allocation register. Uniqueness of every TrakRF tag +// number rests on data/tag-allocations/blocks.csv being contiguous and +// consistent, and the public register page being exactly what that data +// renders. Run from `pnpm lint`. `--data DIR` checks another copy (tests). + +import { readFile } from "node:fs/promises"; +import { + defaultDataDir, + pagePath, + readRegister, + renderPage, + validate, +} from "./lib/tag-allocations.mjs"; + +const dataFlag = process.argv.indexOf("--data"); +const dataDir = dataFlag === -1 ? defaultDataDir : process.argv[dataFlag + 1]; + +const register = await readRegister(dataDir); +const errors = validate(register); + +if (dataDir === defaultDataDir) { + const onDisk = await readFile(pagePath, "utf8").catch(() => ""); + if (onDisk !== (await renderPage(register))) + errors.push( + "docs/integrations/tag-allocations.md does not match the data; run `node scripts/allocate-tags.mjs page`", + ); +} + +if (errors.length) { + console.error(`Tag allocation register: ${errors.length} problem(s)`); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} +console.log( + `Tag allocation register: ${register.blocks.length} block(s), ${register.namespaces.length} namespace(s), consistent.`, +); diff --git a/scripts/check-tag-numbering.mjs b/scripts/check-tag-numbering.mjs new file mode 100644 index 0000000..1f562bd --- /dev/null +++ b/scripts/check-tag-numbering.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Decodes every example EPC on the tag numbering page, so an example is never +// checked by eye. Bureaus encode from this page, and a wrong URN or a +// mis-laid field there ends up in the chip. Run from `pnpm lint`. +// +// Rules: +// - every 24-hex-character value is a GID-96 EPC under manager 98872573 +// - an EPC followed by a URN on one line (arrow or table row) decodes to it +// - every URN on the page is the decoding of some EPC on the page +// - a standard-pool EPC (namespace 000) has 0000 reserved and an 8-digit +// decimal sequence, 10000001–99999999 + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pagePath = join(repoRoot, "docs/integrations/tag-numbering.md"); +const page = readFileSync(pagePath, "utf8"); + +const MANAGER = 98872573n; +const EPC_RE = /(?> 88n; + const manager = (v >> 60n) & ((1n << 28n) - 1n); + const objectClass = (v >> 36n) & ((1n << 24n) - 1n); + const serial = v & ((1n << 36n) - 1n); + return { + header, + manager, + urn: `urn:epc:id:gid:${manager}.${objectClass}.${serial}`, + }; +} + +const errors = []; +const epcs = [...new Set(page.match(EPC_RE) ?? [])]; +if (epcs.length === 0) errors.push("no example EPC found on the page"); + +const decoded = new Set(); +for (const epc of epcs) { + if (epc !== epc.toUpperCase()) + errors.push(`${epc}: write EPCs in uppercase hex`); + const d = decode(epc); + decoded.add(d.urn); + if (d.header !== 0x35n) errors.push(`${epc}: header is not 35 (GID-96)`); + if (d.manager !== MANAGER) + errors.push(`${epc}: manager number is ${d.manager}, not ${MANAGER}`); + if (epc.slice(9, 12) === "000") { + if (epc.slice(12, 16) !== "0000") + errors.push( + `${epc}: standard pool, reserved characters 13–16 are not 0000`, + ); + if (!/^[1-9][0-9]{7}$/.test(epc.slice(16))) + errors.push( + `${epc}: standard pool, sequence is not 8 decimal digits from 10000001`, + ); + } +} + +for (const [i, line] of page.split("\n").entries()) { + const pair = /([0-9A-F]{24})[^0-9A-Za-z]*(urn:epc:id:gid:\d+\.\d+\.\d+)/g; + for (const [, epc, urn] of line.matchAll(pair)) { + const want = decode(epc).urn; + if (urn !== want) + errors.push(`line ${i + 1}: ${epc} decodes to ${want}, page says ${urn}`); + } +} + +for (const urn of new Set(page.match(URN_RE) ?? [])) { + if (!decoded.has(urn)) + errors.push(`${urn}: not the decoding of any EPC on the page`); +} + +if (errors.length) { + console.error( + `docs/integrations/tag-numbering.md: ${errors.length} problem(s)`, + ); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} +console.log( + `tag-numbering.md: ${epcs.length} example EPC(s) decode as stated.`, +); diff --git a/scripts/lib/tag-allocations.mjs b/scripts/lib/tag-allocations.mjs new file mode 100644 index 0000000..3c378e5 --- /dev/null +++ b/scripts/lib/tag-allocations.mjs @@ -0,0 +1,265 @@ +// The TrakRF tag allocation register: one row per standard-pool block, one +// row per namespace code. Shared by the allocator (scripts/allocate-tags.mjs) +// and the lint check (scripts/check-tag-allocations.mjs) so both apply the +// same rules. The public page docs/integrations/tag-allocations.md is +// rendered from these files and must never be edited by hand. + +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import * as prettier from "prettier"; + +export const repoRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + "../..", +); +export const defaultDataDir = join(repoRoot, "data/tag-allocations"); +export const pagePath = join(repoRoot, "docs/integrations/tag-allocations.md"); + +export const SEQ_MIN = 10000001; +export const SEQ_MAX = 99999999; +export const STANDARD_PREFIX = "355E4ACFD000" + "0000"; +const BLOCK_COLUMNS = [ + "block_id", + "first", + "last", + "ordered_qty", + "buffer_pct", + "tag_type", + "last_used", + "status", +]; +const NAMESPACE_COLUMNS = ["code", "status", "note"]; +const BLOCK_STATUSES = new Set(["issued", "closed", "void"]); +const NAMESPACE_STATUSES = new Set([ + "standard", + "reserved", + "active", + "retired", +]); +const BLOCK_ID = /^([A-Za-z0-9]+)-L(\d+)$/; + +// --- CSV ------------------------------------------------------------------ + +function parseCsv(text) { + const rows = []; + let row = []; + let field = ""; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"' && text[i + 1] === '"') ((field += '"'), i++); + else if (c === '"') quoted = false; + else field += c; + } else if (c === '"') quoted = true; + else if (c === ",") (row.push(field), (field = "")); + else if (c === "\n" || c === "\r") { + if (c === "\r" && text[i + 1] === "\n") i++; + (row.push(field), rows.push(row), (row = []), (field = "")); + } else field += c; + } + if (field !== "" || row.length) (row.push(field), rows.push(row)); + return rows; +} + +function csvField(value) { + const s = String(value ?? ""); + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function toCsv(columns, records) { + const lines = [columns.join(",")]; + for (const r of records) + lines.push(columns.map((c) => csvField(r[c])).join(",")); + return lines.join("\n") + "\n"; +} + +async function readTable(path, columns) { + const rows = parseCsv(await readFile(path, "utf8")); + const [header, ...body] = rows; + if (header?.join(",") !== columns.join(",")) + throw new Error(`${path}: header must be ${columns.join(",")}`); + return body.map((cells, i) => { + if (cells.length !== columns.length) + throw new Error( + `${path}: row ${i + 2} has ${cells.length} fields, expected ${columns.length}`, + ); + return Object.fromEntries(columns.map((c, j) => [c, cells[j]])); + }); +} + +export async function readRegister(dataDir = defaultDataDir) { + return { + blocks: await readTable(join(dataDir, "blocks.csv"), BLOCK_COLUMNS), + namespaces: await readTable( + join(dataDir, "namespaces.csv"), + NAMESPACE_COLUMNS, + ), + }; +} + +export async function writeBlocks(blocks, dataDir = defaultDataDir) { + await writeFile(join(dataDir, "blocks.csv"), toCsv(BLOCK_COLUMNS, blocks)); +} + +// --- Rules ---------------------------------------------------------------- + +export const bufferFor = (qty, pct) => Math.ceil((qty * pct) / 100); + +const isSeq = (s) => /^[0-9]{8}$/.test(s) && +s >= SEQ_MIN && +s <= SEQ_MAX; + +export function validate({ blocks, namespaces }) { + const errors = []; + const ids = new Set(); + let expectedFirst = SEQ_MIN; + for (const [i, b] of blocks.entries()) { + const at = `blocks.csv row ${i + 2} (${b.block_id || "no id"})`; + if (!BLOCK_ID.test(b.block_id)) + errors.push(`${at}: block_id must look like -L`); + if (ids.has(b.block_id)) errors.push(`${at}: duplicate block_id`); + ids.add(b.block_id); + for (const f of ["first", "last"]) + if (!isSeq(b[f])) + errors.push(`${at}: ${f} must be 8 digits in ${SEQ_MIN}–${SEQ_MAX}`); + if (!/^[1-9][0-9]*$/.test(b.ordered_qty)) + errors.push(`${at}: ordered_qty must be a positive integer`); + if (!/^[0-9]+$/.test(b.buffer_pct)) + errors.push(`${at}: buffer_pct must be a whole number`); + if (!BLOCK_STATUSES.has(b.status)) + errors.push(`${at}: status must be issued, closed or void`); + if (b.last_used !== "" && !isSeq(b.last_used)) + errors.push(`${at}: last_used must be empty or 8 digits`); + if (+b.first !== expectedFirst) + errors.push( + `${at}: first is ${b.first}, expected ${expectedFirst} (blocks must be contiguous, no gaps or overlaps)`, + ); + const size = +b.ordered_qty + bufferFor(+b.ordered_qty, +b.buffer_pct); + if (+b.last - +b.first + 1 !== size) + errors.push( + `${at}: range holds ${+b.last - +b.first + 1} numbers, ordered_qty + buffer is ${size}`, + ); + if ( + b.last_used !== "" && + (+b.last_used < +b.first || +b.last_used > +b.last) + ) + errors.push( + `${at}: last_used ${b.last_used} is outside ${b.first}–${b.last}`, + ); + if (b.status === "closed" && b.last_used === "") + errors.push(`${at}: closed requires last_used`); + expectedFirst = +b.last + 1; + } + const codes = new Set(); + for (const [i, n] of namespaces.entries()) { + const at = `namespaces.csv row ${i + 2} (${n.code || "no code"})`; + if (!/^[0-9A-F]{3}$/.test(n.code)) + errors.push(`${at}: code must be 3 uppercase hex characters`); + if (codes.has(n.code)) errors.push(`${at}: duplicate code`); + codes.add(n.code); + if (!NAMESPACE_STATUSES.has(n.status)) + errors.push( + `${at}: status must be standard, reserved, active or retired`, + ); + if (n.code === "000" && n.status !== "standard") + errors.push(`${at}: 000 must be standard`); + if (n.code === "0A5" && n.status === "active") + errors.push( + `${at}: 0A5 is the documentation example and is never active`, + ); + } + if (!codes.has("000")) + errors.push("namespaces.csv: 000 (standard pool) is missing"); + return errors; +} + +// --- Allocation and export ----------------------------------------------- + +export function nextFirst(blocks) { + return blocks.length ? Math.max(...blocks.map((b) => +b.last)) + 1 : SEQ_MIN; +} + +export const epcFor = (sequence) => STANDARD_PREFIX + String(sequence); + +export function exportRows(block) { + const [, order, line] = block.block_id.match(BLOCK_ID); + const orderedLast = +block.first + +block.ordered_qty - 1; + const rows = []; + for (let seq = +block.first; seq <= +block.last; seq++) { + rows.push({ + order, + line, + roll: 1, + tag_type: block.tag_type, + sequence: seq, + epc_hex: epcFor(seq), + barcode: seq, + human_readable: seq, + overrun: seq > orderedLast ? "yes" : "no", + }); + } + return rows; +} + +export const EXPORT_COLUMNS = [ + "order", + "line", + "roll", + "tag_type", + "sequence", + "epc_hex", + "barcode", + "human_readable", + "overrun", +]; + +// --- Page ----------------------------------------------------------------- + +const cell = (s) => String(s).replace(/\|/g, "\\|"); + +export async function renderPage({ blocks, namespaces }) { + const blockRows = blocks.map((b) => { + const buffer = +b.last - +b.first + 1 - +b.ordered_qty; + return `| ${cell(b.block_id)} | ${b.first} | ${b.last} | ${b.ordered_qty} | ${buffer} | ${cell(b.tag_type)} | ${b.status} | ${b.last_used || "—"} |`; + }); + const nsRows = namespaces.map( + (n) => `| \`${n.code}\` | ${n.status} | ${cell(n.note)} |`, + ); + const md = `--- +# Generated by scripts/allocate-tags.mjs from data/tag-allocations/. Do not edit: +# pnpm lint fails if this page and the data disagree. +sidebar_position: 5 +title: Tag allocation register +description: Every block of TrakRF standard-pool tag numbers issued so far, and every namespace code assigned. +--- + +# Tag allocation register + +Every block of [standard-pool](./tag-numbering#how-the-numbers-are-issued) tag numbers TrakRF has issued, and every namespace code assigned. Numbers are drawn from one global counter, in order, and never reissued. + +## Standard-pool blocks + +The next block starts at **${nextFirst(blocks)}**. + +| Block | First | Last | Ordered | Buffer | Tag type | Status | Last used | +| --- | --- | --- | --- | --- | --- | --- | --- | +${blockRows.join("\n")} + +- **Ordered** is the first part of the block; **Buffer** is the overrun allowance at its end. +- **Status**: \`issued\` (with the bureau), \`closed\` (last number used reported, the rest retired), or \`void\` (cancelled, never reissued). + +## Namespaces + +| Code | Status | Note | +| --- | --- | --- | +${nsRows.join("\n")} + +Customer namespaces control their own sequencing, so they have no blocks here. A code is never reassigned, even after a customer leaves. +`; + const config = (await prettier.resolveConfig(pagePath)) ?? {}; + return prettier.format(md, { ...config, filepath: pagePath }); +} + +export async function writePage(register) { + await writeFile(pagePath, await renderPage(register)); +} diff --git a/sidebars.ts b/sidebars.ts index 6f492c7..4e7735d 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -75,6 +75,8 @@ const sidebars: SidebarsConfig = { items: [ "integrations/mqtt-message-format", "integrations/fixed-reader-setup", + "integrations/tag-numbering", + "integrations/tag-allocations", ], }, ],