|
|
|
@@ -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",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|