feat: simplify registry number format to B-2026-00001
New format: single-letter prefix + year + 5-digit sequence. No direction code (IN/OUT) in the number — shown via arrow icon. Sequence is shared across directions within the same company+year. Changes: - REGISTRY_COMPANY_PREFIX: BTG→B, USW→U, SDT→S, GRP→G - OLD_COMPANY_PREFIX map for backward compat with existing entries - allocateSequenceNumber: searches both old and new format entries to find the actual max sequence (backward compat) - recalculateSequence: same dual-format search - parseRegistryNumber: supports 3 formats (current, v1, legacy) - isNewFormat: updated regex for B-2026-00001 - CompactNumber: already used single-letter badges, just updated comment - debug-sequences endpoint: updated for new format - Notification test data: updated to new format - RegistrySequence.type: now "SEQ" (shared) instead of "IN"/"OUT" After deploy: POST /api/registratura/debug-sequences to clean up old counters, then recreate test entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
import type { RegistryEntry, RegistryAttachment, RegistryDirection } from "../types";
|
||||
import { REGISTRY_COMPANY_PREFIX, DIRECTION_TYPE_CODE } from "../types";
|
||||
import { REGISTRY_COMPANY_PREFIX, OLD_COMPANY_PREFIX } from "../types";
|
||||
|
||||
const STORAGE_PREFIX = "entry:";
|
||||
|
||||
@@ -212,11 +212,15 @@ export function generateRegistryNumber(
|
||||
return `${prefix}-${padded}/${year}`;
|
||||
}
|
||||
|
||||
// ── New-format numbering (server-side, atomic) ──
|
||||
// ── Registry numbering (server-side, atomic) ──
|
||||
//
|
||||
// Format: B-2026-00001 (single-letter prefix, year, 5-digit sequence)
|
||||
// Direction (intrat/iesit) is NOT part of the number — shown via icon.
|
||||
// Sequence is shared across directions within the same company+year.
|
||||
|
||||
/**
|
||||
* Allocate the next sequence number atomically via PostgreSQL.
|
||||
* Format: BTG-2026-IN-00001
|
||||
* Format: B-2026-00001
|
||||
*
|
||||
* Uses an interactive Prisma transaction with pg_advisory_xact_lock to
|
||||
* serialize concurrent allocations. Always reads the **actual max
|
||||
@@ -224,54 +228,75 @@ export function generateRegistryNumber(
|
||||
* never drifts — even after entry deletions, company reassignments, or
|
||||
* any other mutation that could leave the RegistrySequence counter stale.
|
||||
*
|
||||
* The `direction` parameter is kept in the signature for API compat but
|
||||
* is NOT used in the number format — all directions share one sequence.
|
||||
*
|
||||
* Must be called from server-side only (API routes).
|
||||
*/
|
||||
export async function allocateSequenceNumber(
|
||||
company: CompanyId,
|
||||
direction: RegistryDirection,
|
||||
_direction: RegistryDirection,
|
||||
year?: number,
|
||||
): Promise<{ number: string; sequence: number }> {
|
||||
// Dynamic import — prisma is only available server-side
|
||||
const { prisma } = await import("@/core/storage/prisma");
|
||||
|
||||
const companyPrefix = REGISTRY_COMPANY_PREFIX[company];
|
||||
const typeCode = DIRECTION_TYPE_CODE[direction];
|
||||
const yr = year ?? new Date().getFullYear();
|
||||
|
||||
const seq = await prisma.$transaction(async (tx) => {
|
||||
// Advisory lock scoped to this transaction — serializes concurrent
|
||||
// allocations for the same company/year/type combination.
|
||||
const lockKey = `registratura:${companyPrefix}-${yr}-${typeCode}`;
|
||||
// allocations for the same company/year combination.
|
||||
const lockKey = `registratura:${companyPrefix}-${yr}`;
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
|
||||
|
||||
// 1. Find actual max sequence from entries stored in KeyValueStore.
|
||||
// This is the SOURCE OF TRUTH — entries might have been deleted,
|
||||
// moved between companies, etc., making the RegistrySequence
|
||||
// counter stale.
|
||||
const numberLike = `%"number":"${companyPrefix}-${yr}-${typeCode}-%"%`;
|
||||
const regexPat = `${companyPrefix}-${yr}-${typeCode}-(\\d{5})`;
|
||||
const maxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
// This is the SOURCE OF TRUTH.
|
||||
// Search BOTH new format (B-2026-00001) and old format (BTG-2026-OUT-00001)
|
||||
// for backward compat with entries created before the format change.
|
||||
const oldPrefix = OLD_COMPANY_PREFIX[companyPrefix] ?? "";
|
||||
|
||||
// New format: B-2026-00001
|
||||
const newLike = `%"number":"${companyPrefix}-${yr}-%"%`;
|
||||
const newRegex = `${companyPrefix}-${yr}-(\\d{5})`;
|
||||
const newMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING(value::text FROM ${regexPat}) AS INTEGER)
|
||||
CAST(SUBSTRING(value::text FROM ${newRegex}) AS INTEGER)
|
||||
) AS "maxSeq"
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text LIKE ${numberLike}
|
||||
AND value::text LIKE ${newLike}
|
||||
`;
|
||||
const actualMax = maxRows[0]?.maxSeq ?? 0;
|
||||
const newMax = newMaxRows[0]?.maxSeq ?? 0;
|
||||
|
||||
// Old format: BTG-2026-IN-00001 / BTG-2026-OUT-00001
|
||||
let oldMax = 0;
|
||||
if (oldPrefix) {
|
||||
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`;
|
||||
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-(\\d{5})`;
|
||||
const oldMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING(value::text FROM ${oldRegex}) AS INTEGER)
|
||||
) AS "maxSeq"
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text LIKE ${oldLike}
|
||||
`;
|
||||
oldMax = oldMaxRows[0]?.maxSeq ?? 0;
|
||||
}
|
||||
|
||||
const actualMax = Math.max(newMax, oldMax);
|
||||
|
||||
// 2. Next sequence = actual entries max + 1.
|
||||
// Entries in KeyValueStore are the SOLE source of truth.
|
||||
// The RegistrySequence counter is only a cache — if it drifted
|
||||
// (e.g. entries were deleted before the recalculate fix), we
|
||||
// ignore it entirely and reset it to match reality.
|
||||
const nextSeq = actualMax + 1;
|
||||
|
||||
// 3. Upsert the counter to the new value (keep it in sync)
|
||||
// 3. Upsert the counter (type = "SEQ" — shared across directions)
|
||||
const seqType = "SEQ";
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${typeCode}, ${nextSeq}, NOW(), NOW())
|
||||
VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${seqType}, ${nextSeq}, NOW(), NOW())
|
||||
ON CONFLICT (company, year, type)
|
||||
DO UPDATE SET "lastSeq" = ${nextSeq}, "updatedAt" = NOW()
|
||||
`;
|
||||
@@ -282,7 +307,7 @@ export async function allocateSequenceNumber(
|
||||
const padded = String(seq).padStart(5, "0");
|
||||
|
||||
return {
|
||||
number: `${companyPrefix}-${yr}-${typeCode}-${padded}`,
|
||||
number: `${companyPrefix}-${yr}-${padded}`,
|
||||
sequence: seq,
|
||||
};
|
||||
}
|
||||
@@ -291,43 +316,71 @@ export async function allocateSequenceNumber(
|
||||
* Recalculate a company's sequence counter to match actual entries in the DB.
|
||||
* Called when an entry is reassigned away from a company, so the counter
|
||||
* reflects the real max sequence instead of staying artificially high.
|
||||
*
|
||||
* The `direction` parameter is kept for API compat but ignored — sequence
|
||||
* is shared across directions.
|
||||
*/
|
||||
export async function recalculateSequence(
|
||||
company: CompanyId,
|
||||
direction: RegistryDirection,
|
||||
_direction: RegistryDirection,
|
||||
year?: number,
|
||||
): Promise<void> {
|
||||
const { prisma } = await import("@/core/storage/prisma");
|
||||
|
||||
const companyPrefix = REGISTRY_COMPANY_PREFIX[company];
|
||||
const typeCode = DIRECTION_TYPE_CODE[direction];
|
||||
const yr = year ?? new Date().getFullYear();
|
||||
const oldPrefix = OLD_COMPANY_PREFIX[companyPrefix] ?? "";
|
||||
const seqType = "SEQ";
|
||||
|
||||
// Find the actual max sequence from entries in KeyValueStore
|
||||
const pattern = `${companyPrefix}-${yr}-${typeCode}-%`;
|
||||
const rows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
// Find max from new format (B-2026-00001)
|
||||
const newLike = `%"number":"${companyPrefix}-${yr}-%"%`;
|
||||
const newRegex = `${companyPrefix}-${yr}-(\\d{5})`;
|
||||
const newRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING(value::text FROM ${`${companyPrefix}-${yr}-${typeCode}-(\\d{5})`}) AS INTEGER)
|
||||
CAST(SUBSTRING(value::text FROM ${newRegex}) AS INTEGER)
|
||||
) AS "maxSeq"
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text LIKE ${`%"number":"${pattern}"%`}
|
||||
AND value::text LIKE ${newLike}
|
||||
`;
|
||||
let actualMax = newRows[0]?.maxSeq ?? 0;
|
||||
|
||||
const actualMax = rows[0]?.maxSeq ?? 0;
|
||||
// Also check old format (BTG-2026-OUT-00001)
|
||||
if (oldPrefix) {
|
||||
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`;
|
||||
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-(\\d{5})`;
|
||||
const oldRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
CAST(SUBSTRING(value::text FROM ${oldRegex}) AS INTEGER)
|
||||
) AS "maxSeq"
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text LIKE ${oldLike}
|
||||
`;
|
||||
actualMax = Math.max(actualMax, oldRows[0]?.maxSeq ?? 0);
|
||||
}
|
||||
|
||||
// Reset the counter to the actual max (or delete if 0)
|
||||
if (actualMax === 0) {
|
||||
await prisma.$executeRaw`
|
||||
DELETE FROM "RegistrySequence"
|
||||
WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${typeCode}
|
||||
WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${seqType}
|
||||
`;
|
||||
// Also clean up old-format counters
|
||||
if (oldPrefix) {
|
||||
await prisma.$executeRaw`
|
||||
DELETE FROM "RegistrySequence"
|
||||
WHERE company = ${oldPrefix} AND year = ${yr}
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "RegistrySequence"
|
||||
SET "lastSeq" = ${actualMax}, "updatedAt" = NOW()
|
||||
WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${typeCode}
|
||||
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${seqType}, ${actualMax}, NOW(), NOW())
|
||||
ON CONFLICT (company, year, type)
|
||||
DO UPDATE SET "lastSeq" = ${actualMax}, "updatedAt" = NOW()
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -339,38 +392,49 @@ export interface ParsedRegistryNumber {
|
||||
year: number;
|
||||
type?: string;
|
||||
sequence: number;
|
||||
format: "old" | "new";
|
||||
format: "current" | "v1" | "legacy";
|
||||
}
|
||||
|
||||
/** Detect whether a number uses the new format (BTG-2026-IN-00125) */
|
||||
/** Detect whether a number uses the current format (B-2026-00001) */
|
||||
export function isNewFormat(num: string): boolean {
|
||||
return /^(BTG|SDT|USW|GRP)-\d{4}-(IN|OUT|INT)-\d{5}$/.test(num);
|
||||
return /^[A-Z]-\d{4}-\d{5}$/.test(num);
|
||||
}
|
||||
|
||||
/** Parse a registry number in either old or new format */
|
||||
/** Parse a registry number in any supported format */
|
||||
export function parseRegistryNumber(num: string): ParsedRegistryNumber | null {
|
||||
// New format: BTG-2026-IN-00125
|
||||
const newMatch = num.match(
|
||||
/^(BTG|SDT|USW|GRP)-(\d{4})-(IN|OUT|INT)-(\d{5})$/,
|
||||
);
|
||||
if (newMatch) {
|
||||
// Current format: B-2026-00001
|
||||
const currentMatch = num.match(/^([A-Z])-(\d{4})-(\d{5})$/);
|
||||
if (currentMatch) {
|
||||
return {
|
||||
company: newMatch[1]!,
|
||||
year: parseInt(newMatch[2]!, 10),
|
||||
type: newMatch[3]!,
|
||||
sequence: parseInt(newMatch[4]!, 10),
|
||||
format: "new",
|
||||
company: currentMatch[1]!,
|
||||
year: parseInt(currentMatch[2]!, 10),
|
||||
sequence: parseInt(currentMatch[3]!, 10),
|
||||
format: "current",
|
||||
};
|
||||
}
|
||||
|
||||
// Old format: B-0001/2026
|
||||
const oldMatch = num.match(/^(B|US|SDT|G)-(\d+)\/(\d{4})$/);
|
||||
if (oldMatch) {
|
||||
// V1 format (3-letter prefix + direction): BTG-2026-IN-00125
|
||||
const v1Match = num.match(
|
||||
/^(BTG|SDT|USW|GRP)-(\d{4})-(IN|OUT|INT)-(\d{5})$/,
|
||||
);
|
||||
if (v1Match) {
|
||||
return {
|
||||
company: oldMatch[1]!,
|
||||
year: parseInt(oldMatch[3]!, 10),
|
||||
sequence: parseInt(oldMatch[2]!, 10),
|
||||
format: "old",
|
||||
company: v1Match[1]!,
|
||||
year: parseInt(v1Match[2]!, 10),
|
||||
type: v1Match[3]!,
|
||||
sequence: parseInt(v1Match[4]!, 10),
|
||||
format: "v1",
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy format: B-0001/2026
|
||||
const legacyMatch = num.match(/^(B|US|SDT|G)-(\d+)\/(\d{4})$/);
|
||||
if (legacyMatch) {
|
||||
return {
|
||||
company: legacyMatch[1]!,
|
||||
year: parseInt(legacyMatch[3]!, 10),
|
||||
sequence: parseInt(legacyMatch[2]!, 10),
|
||||
format: "legacy",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user