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:
AI Assistant
2026-03-11 21:49:35 +02:00
parent eb39024548
commit 0f555c55ee
9 changed files with 175 additions and 85 deletions
@@ -22,13 +22,28 @@ export async function GET() {
Array<{ company: string; year: number; type: string; lastSeq: number }>
>`SELECT company, year, type, "lastSeq" FROM "RegistrySequence" ORDER BY company, year, type`;
// Get actual max sequences from entries
// Use Prisma.$queryRawUnsafe to avoid tagged-template escaping issues with regex
// Get actual max sequences from entries (current format: B-2026-00001)
const actuals = await prisma.$queryRawUnsafe<
Array<{ prefix: string; maxSeq: number; count: number }>
>(`
SELECT
SUBSTRING(value::text FROM '"number":"([A-Z]{3}-\\d{4}-(?:IN|OUT|INT))-') AS prefix,
SUBSTRING(value::text FROM '"number":"([A-Z]-\\d{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-\\d{4}-(\\d{5})"') AS INTEGER)) AS "maxSeq",
COUNT(*)::int AS count
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number":"[A-Z]-\\d{4}-\\d{5}"'
GROUP BY prefix
ORDER BY prefix
`);
// Also check for old-format entries (BTG-2026-OUT-00001)
const oldFormatActuals = await prisma.$queryRawUnsafe<
Array<{ prefix: string; maxSeq: number; count: number }>
>(`
SELECT
SUBSTRING(value::text FROM '"number":"([A-Z]{3}-\\d{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]{3}-\\d{4}-(?:IN|OUT|INT)-(\\d{5})"') AS INTEGER)) AS "maxSeq",
COUNT(*)::int AS count
FROM "KeyValueStore"
@@ -41,7 +56,8 @@ export async function GET() {
return NextResponse.json({
counters,
actualEntries: actuals,
currentFormatEntries: actuals,
oldFormatEntries: oldFormatActuals,
note: "POST to this endpoint to reset all counters to match actual entries",
});
}
@@ -52,31 +68,31 @@ export async function POST() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Delete all counters
// Delete ALL old counters (including old-format BTG/SDT/USW/GRP and IN/OUT types)
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
// Re-create counters from actual entries
const inserted = await prisma.$executeRawUnsafe(`
// Re-create counters from actual entries in current format (B-2026-00001)
const insertedNew = await prisma.$executeRawUnsafe(`
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
SELECT
gen_random_uuid()::text,
SUBSTRING(value::text FROM '"number":"([A-Z]+)-') AS company,
CAST(SUBSTRING(value::text FROM '"number":"[A-Z]+-(\\d{4})-') AS INTEGER) AS year,
SUBSTRING(value::text FROM '"number":"[A-Z]+-\\d{4}-([A-Z]+)-') AS type,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]+-\\d{4}-[A-Z]+-(\\d{5})"') AS INTEGER)) AS "lastSeq",
SUBSTRING(value::text FROM '"number":"([A-Z])-') AS company,
CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-(\\d{4})-') AS INTEGER) AS year,
'SEQ' AS type,
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-\\d{4}-(\\d{5})"') AS INTEGER)) AS "lastSeq",
NOW(),
NOW()
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number":"[A-Z]{3}-\\d{4}-(IN|OUT|INT)-\\d{5}"'
AND value::text ~ '"number":"[A-Z]-\\d{4}-\\d{5}"'
GROUP BY company, year, type
`);
return NextResponse.json({
success: true,
deletedCounters: deleted,
recreatedCounters: inserted,
message: "All sequence counters reset to match actual entries",
recreatedCounters: insertedNew,
message: "All sequence counters reset. Old-format counters (BTG/SDT/USW/GRP, IN/OUT) removed.",
});
}
@@ -416,7 +416,7 @@ export async function sendTestDigest(): Promise<DigestResult> {
title: "Termene urgente (5 zile sau mai putin)",
items: [
{
entryNumber: "BTG-0001/2026",
entryNumber: "B-2026-00001",
subject: "[TEST] Certificat de urbanism - str. Exemplu nr. 10",
label: "Emitere CU (30 zile lucratoare)",
dueDate: today,
@@ -424,7 +424,7 @@ export async function sendTestDigest(): Promise<DigestResult> {
color: "yellow",
},
{
entryNumber: "BTG-0005/2026",
entryNumber: "B-2026-00005",
subject: "[TEST] Aviz ISU - Proiect rezidential",
label: "Raspuns aviz ISU (15 zile)",
dueDate: today,
@@ -438,7 +438,7 @@ export async function sendTestDigest(): Promise<DigestResult> {
title: "Termene depasite",
items: [
{
entryNumber: "BTG-0003/2026",
entryNumber: "B-2026-00003",
subject: "[TEST] Autorizatie construire - bloc P+4",
label: "Emitere AC (30 zile lucratoare)",
dueDate: "2026-03-01",
@@ -452,7 +452,7 @@ export async function sendTestDigest(): Promise<DigestResult> {
title: "Documente care expira",
items: [
{
entryNumber: "BTG-0010/2025",
entryNumber: "B-2025-00010",
subject: "[TEST] CU nr. 123/2025 - proiect mixt",
label: "Expira curand",
dueDate: "2026-03-25",
+1 -1
View File
@@ -69,7 +69,7 @@ export function defaultPreference(
// ── Digest result types ──
export interface DigestItem {
/** Entry number (e.g., "BTG-0042/2026") */
/** Entry number (e.g., "B-2026-00042") */
entryNumber: string;
/** Entry subject */
subject: string;
@@ -377,7 +377,7 @@ export function RegistraturaModule() {
<div className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Numerotare</p>
<ul className="text-xs text-muted-foreground space-y-1 list-disc pl-4">
<li>Numerele se atribuie automat (BTG-2026-IN-00001)</li>
<li>Numerele se atribuie automat (B-2026-00001)</li>
<li>Nu modifica manual numărul de înregistrare</li>
<li>Fiecare companie are propria secvență</li>
</ul>
@@ -588,7 +588,7 @@ const COMPANY_BADGE: Record<string, { label: string; className: string }> = {
};
function CompactNumber({ entry }: { entry: RegistryEntry }) {
// Extract plain number with leading zeros: "BTG-0042/2026" → "0042/2026"
// Extract plain number: "B-2026-00001" → "2026-00001"
const plain = (entry.number ?? "").replace(/^[A-Z]+-/, "");
const badge = COMPANY_BADGE[entry.company ?? ""] ?? { label: "B", className: "bg-blue-600 text-white" };
+1
View File
@@ -21,4 +21,5 @@ export {
DEFAULT_DOC_TYPE_LABELS,
DIRECTION_TYPE_CODE,
REGISTRY_COMPANY_PREFIX,
OLD_COMPANY_PREFIX,
} from "./types";
@@ -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",
};
}
+15 -6
View File
@@ -4,18 +4,27 @@ import type { CompanyId } from "@/core/auth/types";
/** Document direction */
export type RegistryDirection = "intrat" | "iesit";
/** Maps direction to the numbering type code */
/** Maps direction to the numbering type code (used internally, not in number format) */
export const DIRECTION_TYPE_CODE: Record<RegistryDirection, string> = {
intrat: "IN",
iesit: "OUT",
};
/** New-format company prefixes for registry numbering */
/** Single-letter company prefixes for registry numbering.
* Format: B-2026-00001 (no direction code in the number). */
export const REGISTRY_COMPANY_PREFIX: Record<CompanyId, string> = {
beletage: "BTG",
"urban-switch": "USW",
"studii-de-teren": "SDT",
group: "GRP",
beletage: "B",
"urban-switch": "U",
"studii-de-teren": "S",
group: "G",
};
/** Old 3-letter prefixes — for backward compat when scanning existing entries */
export const OLD_COMPANY_PREFIX: Record<string, string> = {
B: "BTG",
U: "USW",
S: "SDT",
G: "GRP",
};
/** Registration type — normal, late, or claimed from reserved slot */