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:
AI Assistant
2026-03-11 22:22:33 +02:00
parent 4b61d07ffd
commit 5cb438ef67
2 changed files with 88 additions and 20 deletions
@@ -27,7 +27,7 @@ export async function GET() {
Array<{ key: string; num: string | null; snippet: string }> Array<{ key: string; num: string | null; snippet: string }>
>(` >(`
SELECT key, 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 SUBSTRING(value::text FROM 1 FOR 200) AS snippet
FROM "KeyValueStore" FROM "KeyValueStore"
WHERE namespace = 'registratura' WHERE namespace = 'registratura'
@@ -42,13 +42,13 @@ export async function GET() {
Array<{ prefix: string; maxSeq: number; count: number }> Array<{ prefix: string; maxSeq: number; count: number }>
>(` >(`
SELECT SELECT
SUBSTRING(value::text FROM '"number":"([A-Z]-[0-9]{4})-') AS prefix, 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", MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
COUNT(*)::int AS count COUNT(*)::int AS count
FROM "KeyValueStore" FROM "KeyValueStore"
WHERE namespace = 'registratura' WHERE namespace = 'registratura'
AND key LIKE 'entry:%' 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 GROUP BY prefix
ORDER BY prefix ORDER BY prefix
`); `);
@@ -58,13 +58,13 @@ export async function GET() {
Array<{ prefix: string; maxSeq: number; count: number }> Array<{ prefix: string; maxSeq: number; count: number }>
>(` >(`
SELECT SELECT
SUBSTRING(value::text FROM '"number":"([A-Z]{3}-[0-9]{4})-') AS prefix, 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", 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 COUNT(*)::int AS count
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}-[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 GROUP BY prefix
ORDER BY prefix ORDER BY prefix
`); `);
@@ -92,16 +92,16 @@ export async function POST() {
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]-([0-9]{4})-') AS INTEGER) AS year, CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-([0-9]{4})-') AS INTEGER) AS year,
'SEQ' AS type, '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(),
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]-[0-9]{4}-[0-9]{5}"' AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
GROUP BY company, year, type GROUP BY company, year, type
`); `);
@@ -110,22 +110,22 @@ export async function POST() {
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,
CASE SUBSTRING(value::text FROM '"number":"([A-Z]{3})-') CASE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
WHEN 'BTG' THEN 'B' WHEN 'BTG' THEN 'B'
WHEN 'USW' THEN 'U' WHEN 'USW' THEN 'U'
WHEN 'SDT' THEN 'S' WHEN 'SDT' THEN 'S'
WHEN 'GRP' THEN 'G' 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, 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, '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(),
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}-[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 GROUP BY company, year, type
ON CONFLICT (company, year, type) ON CONFLICT (company, year, type)
DO UPDATE SET "lastSeq" = GREATEST("RegistrySequence"."lastSeq", EXCLUDED."lastSeq"), 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).", 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 // New format: B-2026-00001
// NOTE: Use [0-9] instead of \d — PostgreSQL POSIX regex may not support \d // 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 newRegex = `${companyPrefix}-${yr}-([0-9]{5})`;
const newMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>` const newMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX( SELECT MAX(
@@ -274,7 +275,7 @@ export async function allocateSequenceNumber(
// Old format: BTG-2026-IN-00001 / BTG-2026-OUT-00001 // Old format: BTG-2026-IN-00001 / BTG-2026-OUT-00001
let oldMax = 0; let oldMax = 0;
if (oldPrefix) { if (oldPrefix) {
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`; const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`;
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`; const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`;
const oldMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>` const oldMaxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX( SELECT MAX(
@@ -335,7 +336,8 @@ export async function recalculateSequence(
// Find max from new format (B-2026-00001) // Find max from new format (B-2026-00001)
// NOTE: Use [0-9] instead of \d — PostgreSQL POSIX regex may not support \d // 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 newRegex = `${companyPrefix}-${yr}-([0-9]{5})`;
const newRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>` const newRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX( SELECT MAX(
@@ -350,7 +352,7 @@ export async function recalculateSequence(
// Also check old format (BTG-2026-OUT-00001) // Also check old format (BTG-2026-OUT-00001)
if (oldPrefix) { if (oldPrefix) {
const oldLike = `%"number":"${oldPrefix}-${yr}-%"%`; const oldLike = `%"number": "${oldPrefix}-${yr}-%"%`;
const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`; const oldRegex = `${oldPrefix}-${yr}-(?:IN|OUT|INT)-([0-9]{5})`;
const oldRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>` const oldRows = await prisma.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX( SELECT MAX(