fix: JSONB space-after-colon in all registry LIKE/regex patterns
PostgreSQL JSONB value::text serializes JSON with spaces after colons
("number": "B-2026-00001") but all LIKE patterns searched for the
no-space format ("number":"B-2026-00001"), causing zero matches and
every new entry getting sequence #1.
Fixed in allocateSequenceNumber, recalculateSequence, and debug-sequences.
Added PATCH handler to migrate old-format entries (BTG/SDT/USW/GRP)
to new single-letter format (B/S/U/G).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ export async function GET() {
|
||||
Array<{ key: string; num: string | null; snippet: string }>
|
||||
>(`
|
||||
SELECT key,
|
||||
SUBSTRING(value::text FROM '"number":"([^"]+)"') AS num,
|
||||
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num,
|
||||
SUBSTRING(value::text FROM 1 FOR 200) AS snippet
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
@@ -42,13 +42,13 @@ export async function GET() {
|
||||
Array<{ prefix: string; maxSeq: number; count: number }>
|
||||
>(`
|
||||
SELECT
|
||||
SUBSTRING(value::text FROM '"number":"([A-Z]-[0-9]{4})-') AS prefix,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
|
||||
SUBSTRING(value::text FROM '"number": "([A-Z]-[0-9]{4})-') AS prefix,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
|
||||
COUNT(*)::int AS count
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number":"[A-Z]-[0-9]{4}-[0-9]{5}"'
|
||||
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
|
||||
GROUP BY prefix
|
||||
ORDER BY prefix
|
||||
`);
|
||||
@@ -58,13 +58,13 @@ export async function GET() {
|
||||
Array<{ prefix: string; maxSeq: number; count: number }>
|
||||
>(`
|
||||
SELECT
|
||||
SUBSTRING(value::text FROM '"number":"([A-Z]{3}-[0-9]{4})-') AS prefix,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
|
||||
SUBSTRING(value::text FROM '"number": "([A-Z]{3}-[0-9]{4})-') AS prefix,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
|
||||
COUNT(*)::int AS count
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number":"[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
GROUP BY prefix
|
||||
ORDER BY prefix
|
||||
`);
|
||||
@@ -92,16 +92,16 @@ export async function POST() {
|
||||
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]-([0-9]{4})-') AS INTEGER) AS year,
|
||||
SUBSTRING(value::text FROM '"number": "([A-Z])-') AS company,
|
||||
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-([0-9]{4})-') AS INTEGER) AS year,
|
||||
'SEQ' AS type,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number":"[A-Z]-[0-9]{4}-[0-9]{5}"'
|
||||
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
|
||||
GROUP BY company, year, type
|
||||
`);
|
||||
|
||||
@@ -110,22 +110,22 @@ export async function POST() {
|
||||
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
gen_random_uuid()::text,
|
||||
CASE SUBSTRING(value::text FROM '"number":"([A-Z]{3})-')
|
||||
CASE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
|
||||
WHEN 'BTG' THEN 'B'
|
||||
WHEN 'USW' THEN 'U'
|
||||
WHEN 'SDT' THEN 'S'
|
||||
WHEN 'GRP' THEN 'G'
|
||||
ELSE SUBSTRING(value::text FROM '"number":"([A-Z]{3})-')
|
||||
ELSE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
|
||||
END AS company,
|
||||
CAST(SUBSTRING(value::text FROM '"number":"[A-Z]{3}-([0-9]{4})-') AS INTEGER) AS year,
|
||||
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-([0-9]{4})-') AS INTEGER) AS year,
|
||||
'SEQ' AS type,
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number":"[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
|
||||
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number":"[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
GROUP BY company, year, type
|
||||
ON CONFLICT (company, year, type)
|
||||
DO UPDATE SET "lastSeq" = GREATEST("RegistrySequence"."lastSeq", EXCLUDED."lastSeq"),
|
||||
@@ -140,3 +140,69 @@ export async function POST() {
|
||||
message: "All counters reset from actual entries (both old and new format).",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — Migrate old-format entries (BTG/SDT/USW/GRP) to new format (B/S/U/G).
|
||||
* Rewrites the "number" field inside the JSONB value for matching entries.
|
||||
*/
|
||||
export async function PATCH() {
|
||||
const session = await getAuthSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Map old 3-letter prefixes to new single-letter
|
||||
const migrations: Array<{ old: string; new: string }> = [
|
||||
{ old: "BTG", new: "B" },
|
||||
{ old: "SDT", new: "S" },
|
||||
{ old: "USW", new: "U" },
|
||||
{ old: "GRP", new: "G" },
|
||||
];
|
||||
|
||||
const results: Array<{ prefix: string; updated: number }> = [];
|
||||
|
||||
for (const m of migrations) {
|
||||
// Find entries with old-format numbers: BTG-2026-IN-00001, SDT-2026-OUT-00002, etc.
|
||||
const entries = await prisma.$queryRawUnsafe<
|
||||
Array<{ key: string; num: string }>
|
||||
>(`
|
||||
SELECT key,
|
||||
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = 'registratura'
|
||||
AND key LIKE 'entry:%'
|
||||
AND value::text ~ '"number": "${m.old}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
|
||||
`);
|
||||
|
||||
let updated = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.num) continue;
|
||||
|
||||
// Parse: SDT-2026-OUT-00001 → S-2026-00001
|
||||
const match = entry.num.match(
|
||||
new RegExp(`^${m.old}-(\\d{4})-(?:IN|OUT|INT)-(\\d{5})$`)
|
||||
);
|
||||
if (!match) continue;
|
||||
|
||||
const newNumber = `${m.new}-${match[1]}-${match[2]}`;
|
||||
|
||||
// Update the JSONB value — replace the number field
|
||||
await prisma.$executeRawUnsafe(`
|
||||
UPDATE "KeyValueStore"
|
||||
SET value = jsonb_set(value, '{number}', $1::jsonb)
|
||||
WHERE namespace = 'registratura'
|
||||
AND key = $2
|
||||
`, JSON.stringify(newNumber), entry.key);
|
||||
|
||||
updated++;
|
||||
}
|
||||
|
||||
results.push({ prefix: m.old, updated });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
migrations: results,
|
||||
message: "Old-format entries migrated to new format. Run POST to reset counters.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,7 +258,8 @@ export async function allocateSequenceNumber(
|
||||
|
||||
// New format: B-2026-00001
|
||||
// NOTE: Use [0-9] instead of \d — PostgreSQL POSIX regex may not support \d
|
||||
const newLike = `%"number":"${companyPrefix}-${yr}-%"%`;
|
||||
// NOTE: JSONB value::text serializes with a space after colons ("number": "...")
|
||||
const newLike = `%"number": "${companyPrefix}-${yr}-%"%`;
|
||||
const newRegex = `${companyPrefix}-${yr}-([0-9]{5})`;
|
||||
const newMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
@@ -274,7 +275,7 @@ export async function allocateSequenceNumber(
|
||||
// Old format: BTG-2026-IN-00001 / BTG-2026-OUT-00001
|
||||
let oldMax = 0;
|
||||
if (oldPrefix) {
|
||||
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`;
|
||||
const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`;
|
||||
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`;
|
||||
const oldMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
@@ -335,7 +336,8 @@ export async function recalculateSequence(
|
||||
|
||||
// Find max from new format (B-2026-00001)
|
||||
// NOTE: Use [0-9] instead of \d — PostgreSQL POSIX regex may not support \d
|
||||
const newLike = `%"number":"${companyPrefix}-${yr}-%"%`;
|
||||
// NOTE: JSONB value::text serializes with a space after colons ("number": "...")
|
||||
const newLike = `%"number": "${companyPrefix}-${yr}-%"%`;
|
||||
const newRegex = `${companyPrefix}-${yr}-([0-9]{5})`;
|
||||
const newRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
@@ -350,7 +352,7 @@ export async function recalculateSequence(
|
||||
|
||||
// Also check old format (BTG-2026-OUT-00001)
|
||||
if (oldPrefix) {
|
||||
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`;
|
||||
const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`;
|
||||
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`;
|
||||
const oldRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
|
||||
SELECT MAX(
|
||||
|
||||
Reference in New Issue
Block a user