From 0f555c55ee5acd415ef27cc5ab80f26814ccf538 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Wed, 11 Mar 2026 21:49:35 +0200 Subject: [PATCH] feat: simplify registry number format to B-2026-00001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- prisma/schema.prisma | 4 +- .../api/registratura/debug-sequences/route.ts | 44 +++-- .../notifications/notification-service.ts | 8 +- src/core/notifications/types.ts | 2 +- .../components/registratura-module.tsx | 2 +- .../components/registry-table.tsx | 2 +- src/modules/registratura/index.ts | 1 + .../registratura/services/registry-service.ts | 176 ++++++++++++------ src/modules/registratura/types.ts | 21 ++- 9 files changed, 175 insertions(+), 85 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 93567fc..5dc529a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,9 +87,9 @@ model GisUat { model RegistrySequence { id String @id @default(uuid()) - company String // BTG, SDT, USW, GRP + company String // B, U, S, G (single-letter prefix) year Int - type String // IN, OUT, INT + type String // SEQ (shared across directions) lastSeq Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/registratura/debug-sequences/route.ts b/src/app/api/registratura/debug-sequences/route.ts index 9c033c6..1d7576a 100644 --- a/src/app/api/registratura/debug-sequences/route.ts +++ b/src/app/api/registratura/debug-sequences/route.ts @@ -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.", }); } diff --git a/src/core/notifications/notification-service.ts b/src/core/notifications/notification-service.ts index e6e04ca..4348976 100644 --- a/src/core/notifications/notification-service.ts +++ b/src/core/notifications/notification-service.ts @@ -416,7 +416,7 @@ export async function sendTestDigest(): Promise { 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 { 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 { 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 { 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", diff --git a/src/core/notifications/types.ts b/src/core/notifications/types.ts index d144809..da82135 100644 --- a/src/core/notifications/types.ts +++ b/src/core/notifications/types.ts @@ -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; diff --git a/src/modules/registratura/components/registratura-module.tsx b/src/modules/registratura/components/registratura-module.tsx index a759af2..4c67fb7 100644 --- a/src/modules/registratura/components/registratura-module.tsx +++ b/src/modules/registratura/components/registratura-module.tsx @@ -377,7 +377,7 @@ export function RegistraturaModule() {

Numerotare

    -
  • Numerele se atribuie automat (BTG-2026-IN-00001)
  • +
  • Numerele se atribuie automat (B-2026-00001)
  • Nu modifica manual numărul de înregistrare
  • Fiecare companie are propria secvență
diff --git a/src/modules/registratura/components/registry-table.tsx b/src/modules/registratura/components/registry-table.tsx index 848bd18..6d622cb 100644 --- a/src/modules/registratura/components/registry-table.tsx +++ b/src/modules/registratura/components/registry-table.tsx @@ -588,7 +588,7 @@ const COMPANY_BADGE: Record = { }; 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" }; diff --git a/src/modules/registratura/index.ts b/src/modules/registratura/index.ts index ccf349d..f97670d 100644 --- a/src/modules/registratura/index.ts +++ b/src/modules/registratura/index.ts @@ -21,4 +21,5 @@ export { DEFAULT_DOC_TYPE_LABELS, DIRECTION_TYPE_CODE, REGISTRY_COMPANY_PREFIX, + OLD_COMPANY_PREFIX, } from "./types"; diff --git a/src/modules/registratura/services/registry-service.ts b/src/modules/registratura/services/registry-service.ts index 9e606c9..e9b1a16 100644 --- a/src/modules/registratura/services/registry-service.ts +++ b/src/modules/registratura/services/registry-service.ts @@ -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>` + // 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>` 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>` + 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 { 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>` + // Find max from new format (B-2026-00001) + const newLike = `%"number":"${companyPrefix}-${yr}-%"%`; + const newRegex = `${companyPrefix}-${yr}-(\\d{5})`; + const newRows = await prisma.$queryRaw>` 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>` + 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", }; } diff --git a/src/modules/registratura/types.ts b/src/modules/registratura/types.ts index f3354b7..8cb9a1a 100644 --- a/src/modules/registratura/types.ts +++ b/src/modules/registratura/types.ts @@ -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 = { 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 = { - 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 = { + B: "BTG", + U: "USW", + S: "SDT", + G: "GRP", }; /** Registration type — normal, late, or claimed from reserved slot */