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:
@@ -87,9 +87,9 @@ model GisUat {
|
|||||||
|
|
||||||
model RegistrySequence {
|
model RegistrySequence {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
company String // BTG, SDT, USW, GRP
|
company String // B, U, S, G (single-letter prefix)
|
||||||
year Int
|
year Int
|
||||||
type String // IN, OUT, INT
|
type String // SEQ (shared across directions)
|
||||||
lastSeq Int @default(0)
|
lastSeq Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -22,13 +22,28 @@ export async function GET() {
|
|||||||
Array<{ company: string; year: number; type: string; lastSeq: number }>
|
Array<{ company: string; year: number; type: string; lastSeq: number }>
|
||||||
>`SELECT company, year, type, "lastSeq" FROM "RegistrySequence" ORDER BY company, year, type`;
|
>`SELECT company, year, type, "lastSeq" FROM "RegistrySequence" ORDER BY company, year, type`;
|
||||||
|
|
||||||
// Get actual max sequences from entries
|
// Get actual max sequences from entries (current format: B-2026-00001)
|
||||||
// Use Prisma.$queryRawUnsafe to avoid tagged-template escaping issues with regex
|
|
||||||
const actuals = await prisma.$queryRawUnsafe<
|
const actuals = await prisma.$queryRawUnsafe<
|
||||||
Array<{ prefix: string; maxSeq: number; count: number }>
|
Array<{ prefix: string; maxSeq: number; count: number }>
|
||||||
>(`
|
>(`
|
||||||
SELECT
|
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",
|
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
|
COUNT(*)::int AS count
|
||||||
FROM "KeyValueStore"
|
FROM "KeyValueStore"
|
||||||
@@ -41,7 +56,8 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
counters,
|
counters,
|
||||||
actualEntries: actuals,
|
currentFormatEntries: actuals,
|
||||||
|
oldFormatEntries: oldFormatActuals,
|
||||||
note: "POST to this endpoint to reset all counters to match actual entries",
|
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 });
|
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"`;
|
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
|
||||||
|
|
||||||
// Re-create counters from actual entries
|
// Re-create counters from actual entries in current format (B-2026-00001)
|
||||||
const inserted = await prisma.$executeRawUnsafe(`
|
const insertedNew = await prisma.$executeRawUnsafe(`
|
||||||
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
||||||
SELECT
|
SELECT
|
||||||
gen_random_uuid()::text,
|
gen_random_uuid()::text,
|
||||||
SUBSTRING(value::text FROM '"number":"([A-Z]+)-') AS company,
|
SUBSTRING(value::text FROM '"number":"([A-Z])-') AS company,
|
||||||
CAST(SUBSTRING(value::text FROM '"number":"[A-Z]+-(\\d{4})-') AS INTEGER) AS year,
|
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,
|
'SEQ' AS type,
|
||||||
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]+-\\d{4}-[A-Z]+-(\\d{5})"') AS INTEGER)) AS "lastSeq",
|
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-\\d{4}-(\\d{5})"') AS INTEGER)) AS "lastSeq",
|
||||||
NOW(),
|
NOW(),
|
||||||
NOW()
|
NOW()
|
||||||
FROM "KeyValueStore"
|
FROM "KeyValueStore"
|
||||||
WHERE namespace = 'registratura'
|
WHERE namespace = 'registratura'
|
||||||
AND key LIKE 'entry:%'
|
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
|
GROUP BY company, year, type
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
deletedCounters: deleted,
|
deletedCounters: deleted,
|
||||||
recreatedCounters: inserted,
|
recreatedCounters: insertedNew,
|
||||||
message: "All sequence counters reset to match actual entries",
|
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)",
|
title: "Termene urgente (5 zile sau mai putin)",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
entryNumber: "BTG-0001/2026",
|
entryNumber: "B-2026-00001",
|
||||||
subject: "[TEST] Certificat de urbanism - str. Exemplu nr. 10",
|
subject: "[TEST] Certificat de urbanism - str. Exemplu nr. 10",
|
||||||
label: "Emitere CU (30 zile lucratoare)",
|
label: "Emitere CU (30 zile lucratoare)",
|
||||||
dueDate: today,
|
dueDate: today,
|
||||||
@@ -424,7 +424,7 @@ export async function sendTestDigest(): Promise<DigestResult> {
|
|||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
entryNumber: "BTG-0005/2026",
|
entryNumber: "B-2026-00005",
|
||||||
subject: "[TEST] Aviz ISU - Proiect rezidential",
|
subject: "[TEST] Aviz ISU - Proiect rezidential",
|
||||||
label: "Raspuns aviz ISU (15 zile)",
|
label: "Raspuns aviz ISU (15 zile)",
|
||||||
dueDate: today,
|
dueDate: today,
|
||||||
@@ -438,7 +438,7 @@ export async function sendTestDigest(): Promise<DigestResult> {
|
|||||||
title: "Termene depasite",
|
title: "Termene depasite",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
entryNumber: "BTG-0003/2026",
|
entryNumber: "B-2026-00003",
|
||||||
subject: "[TEST] Autorizatie construire - bloc P+4",
|
subject: "[TEST] Autorizatie construire - bloc P+4",
|
||||||
label: "Emitere AC (30 zile lucratoare)",
|
label: "Emitere AC (30 zile lucratoare)",
|
||||||
dueDate: "2026-03-01",
|
dueDate: "2026-03-01",
|
||||||
@@ -452,7 +452,7 @@ export async function sendTestDigest(): Promise<DigestResult> {
|
|||||||
title: "Documente care expira",
|
title: "Documente care expira",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
entryNumber: "BTG-0010/2025",
|
entryNumber: "B-2025-00010",
|
||||||
subject: "[TEST] CU nr. 123/2025 - proiect mixt",
|
subject: "[TEST] CU nr. 123/2025 - proiect mixt",
|
||||||
label: "Expira curand",
|
label: "Expira curand",
|
||||||
dueDate: "2026-03-25",
|
dueDate: "2026-03-25",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function defaultPreference(
|
|||||||
// ── Digest result types ──
|
// ── Digest result types ──
|
||||||
|
|
||||||
export interface DigestItem {
|
export interface DigestItem {
|
||||||
/** Entry number (e.g., "BTG-0042/2026") */
|
/** Entry number (e.g., "B-2026-00042") */
|
||||||
entryNumber: string;
|
entryNumber: string;
|
||||||
/** Entry subject */
|
/** Entry subject */
|
||||||
subject: string;
|
subject: string;
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ export function RegistraturaModule() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Numerotare</p>
|
<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">
|
<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>Nu modifica manual numărul de înregistrare</li>
|
||||||
<li>Fiecare companie are propria secvență</li>
|
<li>Fiecare companie are propria secvență</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -588,7 +588,7 @@ const COMPANY_BADGE: Record<string, { label: string; className: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function CompactNumber({ entry }: { entry: RegistryEntry }) {
|
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 plain = (entry.number ?? "").replace(/^[A-Z]+-/, "");
|
||||||
const badge = COMPANY_BADGE[entry.company ?? ""] ?? { label: "B", className: "bg-blue-600 text-white" };
|
const badge = COMPANY_BADGE[entry.company ?? ""] ?? { label: "B", className: "bg-blue-600 text-white" };
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ export {
|
|||||||
DEFAULT_DOC_TYPE_LABELS,
|
DEFAULT_DOC_TYPE_LABELS,
|
||||||
DIRECTION_TYPE_CODE,
|
DIRECTION_TYPE_CODE,
|
||||||
REGISTRY_COMPANY_PREFIX,
|
REGISTRY_COMPANY_PREFIX,
|
||||||
|
OLD_COMPANY_PREFIX,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CompanyId } from "@/core/auth/types";
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
import type { RegistryEntry, RegistryAttachment, RegistryDirection } from "../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:";
|
const STORAGE_PREFIX = "entry:";
|
||||||
|
|
||||||
@@ -212,11 +212,15 @@ export function generateRegistryNumber(
|
|||||||
return `${prefix}-${padded}/${year}`;
|
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.
|
* 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
|
* Uses an interactive Prisma transaction with pg_advisory_xact_lock to
|
||||||
* serialize concurrent allocations. Always reads the **actual max
|
* serialize concurrent allocations. Always reads the **actual max
|
||||||
@@ -224,54 +228,75 @@ export function generateRegistryNumber(
|
|||||||
* never drifts — even after entry deletions, company reassignments, or
|
* never drifts — even after entry deletions, company reassignments, or
|
||||||
* any other mutation that could leave the RegistrySequence counter stale.
|
* 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).
|
* Must be called from server-side only (API routes).
|
||||||
*/
|
*/
|
||||||
export async function allocateSequenceNumber(
|
export async function allocateSequenceNumber(
|
||||||
company: CompanyId,
|
company: CompanyId,
|
||||||
direction: RegistryDirection,
|
_direction: RegistryDirection,
|
||||||
year?: number,
|
year?: number,
|
||||||
): Promise<{ number: string; sequence: number }> {
|
): Promise<{ number: string; sequence: number }> {
|
||||||
// Dynamic import — prisma is only available server-side
|
// Dynamic import — prisma is only available server-side
|
||||||
const { prisma } = await import("@/core/storage/prisma");
|
const { prisma } = await import("@/core/storage/prisma");
|
||||||
|
|
||||||
const companyPrefix = REGISTRY_COMPANY_PREFIX[company];
|
const companyPrefix = REGISTRY_COMPANY_PREFIX[company];
|
||||||
const typeCode = DIRECTION_TYPE_CODE[direction];
|
|
||||||
const yr = year ?? new Date().getFullYear();
|
const yr = year ?? new Date().getFullYear();
|
||||||
|
|
||||||
const seq = await prisma.$transaction(async (tx) => {
|
const seq = await prisma.$transaction(async (tx) => {
|
||||||
// Advisory lock scoped to this transaction — serializes concurrent
|
// Advisory lock scoped to this transaction — serializes concurrent
|
||||||
// allocations for the same company/year/type combination.
|
// allocations for the same company/year combination.
|
||||||
const lockKey = `registratura:${companyPrefix}-${yr}-${typeCode}`;
|
const lockKey = `registratura:${companyPrefix}-${yr}`;
|
||||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
|
||||||
|
|
||||||
// 1. Find actual max sequence from entries stored in KeyValueStore.
|
// 1. Find actual max sequence from entries stored in KeyValueStore.
|
||||||
// This is the SOURCE OF TRUTH — entries might have been deleted,
|
// This is the SOURCE OF TRUTH.
|
||||||
// moved between companies, etc., making the RegistrySequence
|
// Search BOTH new format (B-2026-00001) and old format (BTG-2026-OUT-00001)
|
||||||
// counter stale.
|
// for backward compat with entries created before the format change.
|
||||||
const numberLike = `%"number":"${companyPrefix}-${yr}-${typeCode}-%"%`;
|
const oldPrefix = OLD_COMPANY_PREFIX[companyPrefix] ?? "";
|
||||||
const regexPat = `${companyPrefix}-${yr}-${typeCode}-(\\d{5})`;
|
|
||||||
const maxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
|
// 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(
|
SELECT MAX(
|
||||||
CAST(SUBSTRING(value::text FROM ${regexPat}) AS INTEGER)
|
CAST(SUBSTRING(value::text FROM ${newRegex}) AS INTEGER)
|
||||||
) AS "maxSeq"
|
) AS "maxSeq"
|
||||||
FROM "KeyValueStore"
|
FROM "KeyValueStore"
|
||||||
WHERE namespace = 'registratura'
|
WHERE namespace = 'registratura'
|
||||||
AND key LIKE 'entry:%'
|
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.
|
// 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;
|
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`
|
await tx.$executeRaw`
|
||||||
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
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)
|
ON CONFLICT (company, year, type)
|
||||||
DO UPDATE SET "lastSeq" = ${nextSeq}, "updatedAt" = NOW()
|
DO UPDATE SET "lastSeq" = ${nextSeq}, "updatedAt" = NOW()
|
||||||
`;
|
`;
|
||||||
@@ -282,7 +307,7 @@ export async function allocateSequenceNumber(
|
|||||||
const padded = String(seq).padStart(5, "0");
|
const padded = String(seq).padStart(5, "0");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
number: `${companyPrefix}-${yr}-${typeCode}-${padded}`,
|
number: `${companyPrefix}-${yr}-${padded}`,
|
||||||
sequence: seq,
|
sequence: seq,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -291,43 +316,71 @@ export async function allocateSequenceNumber(
|
|||||||
* Recalculate a company's sequence counter to match actual entries in the DB.
|
* 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
|
* Called when an entry is reassigned away from a company, so the counter
|
||||||
* reflects the real max sequence instead of staying artificially high.
|
* 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(
|
export async function recalculateSequence(
|
||||||
company: CompanyId,
|
company: CompanyId,
|
||||||
direction: RegistryDirection,
|
_direction: RegistryDirection,
|
||||||
year?: number,
|
year?: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { prisma } = await import("@/core/storage/prisma");
|
const { prisma } = await import("@/core/storage/prisma");
|
||||||
|
|
||||||
const companyPrefix = REGISTRY_COMPANY_PREFIX[company];
|
const companyPrefix = REGISTRY_COMPANY_PREFIX[company];
|
||||||
const typeCode = DIRECTION_TYPE_CODE[direction];
|
|
||||||
const yr = year ?? new Date().getFullYear();
|
const yr = year ?? new Date().getFullYear();
|
||||||
|
const oldPrefix = OLD_COMPANY_PREFIX[companyPrefix] ?? "";
|
||||||
|
const seqType = "SEQ";
|
||||||
|
|
||||||
// Find the actual max sequence from entries in KeyValueStore
|
// Find max from new format (B-2026-00001)
|
||||||
const pattern = `${companyPrefix}-${yr}-${typeCode}-%`;
|
const newLike = `%"number":"${companyPrefix}-${yr}-%"%`;
|
||||||
const rows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
const newRegex = `${companyPrefix}-${yr}-(\\d{5})`;
|
||||||
|
const newRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||||
SELECT MAX(
|
SELECT MAX(
|
||||||
CAST(SUBSTRING(value::text FROM ${`${companyPrefix}-${yr}-${typeCode}-(\\d{5})`}) AS INTEGER)
|
CAST(SUBSTRING(value::text FROM ${newRegex}) AS INTEGER)
|
||||||
) AS "maxSeq"
|
) AS "maxSeq"
|
||||||
FROM "KeyValueStore"
|
FROM "KeyValueStore"
|
||||||
WHERE namespace = 'registratura'
|
WHERE namespace = 'registratura'
|
||||||
AND key LIKE 'entry:%'
|
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)
|
// Reset the counter to the actual max (or delete if 0)
|
||||||
if (actualMax === 0) {
|
if (actualMax === 0) {
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
DELETE FROM "RegistrySequence"
|
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 {
|
} else {
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRaw`
|
||||||
UPDATE "RegistrySequence"
|
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
||||||
SET "lastSeq" = ${actualMax}, "updatedAt" = NOW()
|
VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${seqType}, ${actualMax}, NOW(), NOW())
|
||||||
WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${typeCode}
|
ON CONFLICT (company, year, type)
|
||||||
|
DO UPDATE SET "lastSeq" = ${actualMax}, "updatedAt" = NOW()
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,38 +392,49 @@ export interface ParsedRegistryNumber {
|
|||||||
year: number;
|
year: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
sequence: number;
|
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 {
|
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 {
|
export function parseRegistryNumber(num: string): ParsedRegistryNumber | null {
|
||||||
// New format: BTG-2026-IN-00125
|
// Current format: B-2026-00001
|
||||||
const newMatch = num.match(
|
const currentMatch = num.match(/^([A-Z])-(\d{4})-(\d{5})$/);
|
||||||
/^(BTG|SDT|USW|GRP)-(\d{4})-(IN|OUT|INT)-(\d{5})$/,
|
if (currentMatch) {
|
||||||
);
|
|
||||||
if (newMatch) {
|
|
||||||
return {
|
return {
|
||||||
company: newMatch[1]!,
|
company: currentMatch[1]!,
|
||||||
year: parseInt(newMatch[2]!, 10),
|
year: parseInt(currentMatch[2]!, 10),
|
||||||
type: newMatch[3]!,
|
sequence: parseInt(currentMatch[3]!, 10),
|
||||||
sequence: parseInt(newMatch[4]!, 10),
|
format: "current",
|
||||||
format: "new",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old format: B-0001/2026
|
// V1 format (3-letter prefix + direction): BTG-2026-IN-00125
|
||||||
const oldMatch = num.match(/^(B|US|SDT|G)-(\d+)\/(\d{4})$/);
|
const v1Match = num.match(
|
||||||
if (oldMatch) {
|
/^(BTG|SDT|USW|GRP)-(\d{4})-(IN|OUT|INT)-(\d{5})$/,
|
||||||
|
);
|
||||||
|
if (v1Match) {
|
||||||
return {
|
return {
|
||||||
company: oldMatch[1]!,
|
company: v1Match[1]!,
|
||||||
year: parseInt(oldMatch[3]!, 10),
|
year: parseInt(v1Match[2]!, 10),
|
||||||
sequence: parseInt(oldMatch[2]!, 10),
|
type: v1Match[3]!,
|
||||||
format: "old",
|
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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,27 @@ import type { CompanyId } from "@/core/auth/types";
|
|||||||
/** Document direction */
|
/** Document direction */
|
||||||
export type RegistryDirection = "intrat" | "iesit";
|
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> = {
|
export const DIRECTION_TYPE_CODE: Record<RegistryDirection, string> = {
|
||||||
intrat: "IN",
|
intrat: "IN",
|
||||||
iesit: "OUT",
|
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> = {
|
export const REGISTRY_COMPANY_PREFIX: Record<CompanyId, string> = {
|
||||||
beletage: "BTG",
|
beletage: "B",
|
||||||
"urban-switch": "USW",
|
"urban-switch": "U",
|
||||||
"studii-de-teren": "SDT",
|
"studii-de-teren": "S",
|
||||||
group: "GRP",
|
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 */
|
/** Registration type — normal, late, or claimed from reserved slot */
|
||||||
|
|||||||
Reference in New Issue
Block a user