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 }>
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user