From 2146696bc6a5c802602b4289162f71f10957e606 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 23 Mar 2026 09:00:07 +0100 Subject: [PATCH] refactor: extract numbering logic into numbering.service.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/invoices.ts | 4 +- src/routes/admin/orders.ts | 42 +++--------------- src/routes/admin/projects.ts | 21 ++------- src/routes/admin/quotations.ts | 29 ++----------- src/services/numbering.service.ts | 71 +++++++++++++++++++++++++++++++ src/utils/sequence.ts | 46 -------------------- 6 files changed, 86 insertions(+), 127 deletions(-) create mode 100644 src/services/numbering.service.ts delete mode 100644 src/utils/sequence.ts diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index 7b5f3c2..3e65611 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -4,7 +4,7 @@ import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; -import { getNextNumber } from '../../utils/sequence'; +import { generateInvoiceNumber } from '../../services/numbering.service'; import { parseBody } from '../../schemas/common'; import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema'; @@ -106,7 +106,7 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise< const prefix = `${yy}${typeCode}`; // Atomic numbering via number_sequences table - const nextNum = await getNextNumber('invoice', year); + const nextNum = await generateInvoiceNumber(year); const number = `${prefix}${String(nextNum).padStart(4, '0')}`; return success(reply, { number, next_number: number }); }); diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index 2202777..7a13c4a 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -6,6 +6,7 @@ import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { parseBody } from '../../schemas/common'; import { CreateOrderFromQuotationSchema, CreateOrderSchema, UpdateOrderSchema } from '../../schemas/orders.schema'; +import { generateSharedNumber } from '../../services/numbering.service'; import multipart from '@fastify/multipart'; @@ -17,37 +18,6 @@ const VALID_TRANSITIONS: Record = { stornovana: [], }; -// Shared number generator matching PHP generateSharedNumber() -// Format: YYtypeCode + 4-digit sequence, shared between orders and projects -async function generateSharedNumber(): Promise { - const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } }); - const typeCode = settings?.order_type_code || '71'; - const yy = String(new Date().getFullYear()).slice(-2); - const prefix = `${yy}${typeCode}`; - const prefixLen = prefix.length; - const likePattern = `${prefix}%`; - - const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` - SELECT COALESCE(MAX(seq), 0) as max_seq FROM ( - SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq - FROM orders WHERE order_number LIKE ${likePattern} - UNION ALL - SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq - FROM projects WHERE project_number LIKE ${likePattern} - ) combined - `; - const nextNum = Number(result[0]?.max_seq ?? 0) + 1; - return `${prefix}${String(nextNum).padStart(4, '0')}`; -} - -async function generateOrderNumber(): Promise { - return generateSharedNumber(); -} - -async function generateProjectNumber(): Promise { - return generateSharedNumber(); -} - interface OrderItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number } interface OrderSectionInput { title?: string; title_cz?: string; content?: string; position?: number } @@ -56,7 +26,7 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise { - const number = await generateOrderNumber(); + const number = await generateSharedNumber(); return success(reply, { number, next_number: number }); }); @@ -198,8 +168,8 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise { // Create the order @@ -301,8 +271,8 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise { const order = await tx.orders.create({ diff --git a/src/routes/admin/projects.ts b/src/routes/admin/projects.ts index f63b51a..2c98b55 100644 --- a/src/routes/admin/projects.ts +++ b/src/routes/admin/projects.ts @@ -6,6 +6,7 @@ import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { parseBody } from '../../schemas/common'; import { CreateProjectSchema, UpdateProjectSchema, CreateProjectNoteSchema } from '../../schemas/projects.schema'; +import { generateSharedNumber } from '../../services/numbering.service'; const PROJECT_ALLOWED_SORT_FIELDS = ['id', 'project_number', 'name', 'status', 'created_at']; @@ -125,24 +126,8 @@ export default async function projectsRoutes(fastify: FastifyInstance): Promise< // GET /api/admin/projects/next-number — shared sequence with orders (matches PHP) fastify.get('/next-number', { preHandler: requirePermission('projects.create') }, async (_request, reply) => { - const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } }); - const typeCode = settings?.order_type_code || '71'; - const yy = String(new Date().getFullYear()).slice(-2); - const prefix = `${yy}${typeCode}`; - const prefixLen = prefix.length; - const likePattern = `${prefix}%`; - - const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` - SELECT COALESCE(MAX(seq), 0) as max_seq FROM ( - SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq - FROM orders WHERE order_number LIKE ${likePattern} - UNION ALL - SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq - FROM projects WHERE project_number LIKE ${likePattern} - ) combined - `; - const nextNum = Number(result[0]?.max_seq ?? 0) + 1; - return success(reply, { next_number: `${prefix}${String(nextNum).padStart(4, '0')}` }); + const nextNumber = await generateSharedNumber(); + return success(reply, { next_number: nextNumber }); }); // DELETE /api/admin/projects/:id/notes/:noteId diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts index ecfbc38..7452eb1 100644 --- a/src/routes/admin/quotations.ts +++ b/src/routes/admin/quotations.ts @@ -6,6 +6,7 @@ import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; import { parseBody } from '../../schemas/common'; import { CreateQuotationSchema, UpdateQuotationSchema } from '../../schemas/offers.schema'; +import { generateOfferNumber } from '../../services/numbering.service'; interface QuotationItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number } @@ -68,19 +69,7 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis // GET /api/admin/offers/next-number fastify.get('/next-number', { preHandler: requirePermission('offers.create') }, async (_request, reply) => { - const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } }); - const prefix = settings?.quotation_prefix || 'NA'; - const year = new Date().getFullYear(); - const likePattern = `${year}/${prefix}/%`; - - // Match PHP logic: find MAX number from existing quotations - const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>` - SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num - FROM quotations - WHERE quotation_number LIKE ${likePattern} - `; - const nextNum = Number(result[0]?.max_num ?? 0) + 1; - const number = `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`; + const number = await generateOfferNumber(); return success(reply, { number, next_number: number }); }); @@ -94,21 +83,11 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis }); if (!original) return error(reply, 'Nabídka nenalezena', 404); - // Get next number by querying MAX from existing quotations (matches PHP logic) - const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } }); - const qPrefix = settings?.quotation_prefix || 'NA'; - const year = new Date().getFullYear(); - const likePattern = `${year}/${qPrefix}/%`; - const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>` - SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num - FROM quotations - WHERE quotation_number LIKE ${likePattern} - `; - const nextNum = Number(result[0]?.max_num ?? 0) + 1; + const nextOfferNumber = await generateOfferNumber(); const copy = await prisma.quotations.create({ data: { - quotation_number: `${year}/${qPrefix}/${String(nextNum).padStart(3, '0')}`, + quotation_number: nextOfferNumber, project_code: original.project_code, customer_id: original.customer_id, valid_until: null, diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts new file mode 100644 index 0000000..887bbb1 --- /dev/null +++ b/src/services/numbering.service.ts @@ -0,0 +1,71 @@ +import prisma from '../config/database'; + +/** + * Shared number generator for orders and projects. + * Format: YYtypeCode + 4-digit sequence (e.g., 26710003) + * Queries MAX from both orders and projects tables. + */ +export async function generateSharedNumber(): Promise { + const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } }); + const typeCode = settings?.order_type_code || '71'; + const yy = String(new Date().getFullYear()).slice(-2); + const prefix = `${yy}${typeCode}`; + const prefixLen = prefix.length; + const likePattern = `${prefix}%`; + + const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` + SELECT COALESCE(MAX(seq), 0) as max_seq FROM ( + SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq + FROM orders WHERE order_number LIKE ${likePattern} + UNION ALL + SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq + FROM projects WHERE project_number LIKE ${likePattern} + ) combined + `; + const nextNum = Number(result[0]?.max_seq ?? 0) + 1; + return `${prefix}${String(nextNum).padStart(4, '0')}`; +} + +/** + * Next offer number. Queries MAX from quotations table. + * Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008) + */ +export async function generateOfferNumber(): Promise { + const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } }); + const prefix = settings?.quotation_prefix || 'NA'; + const year = new Date().getFullYear(); + const likePattern = `${year}/${prefix}/%`; + + const result = await prisma.$queryRaw<[{ max_num: bigint | null }]>` + SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num + FROM quotations + WHERE quotation_number LIKE ${likePattern} + `; + const nextNum = Number(result[0]?.max_num ?? 0) + 1; + return `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`; +} + +/** + * Next invoice number via atomic sequence table. + */ +export async function generateInvoiceNumber(year: number): Promise { + return prisma.$transaction(async (tx) => { + const existing = await tx.number_sequences.findFirst({ + where: { type: 'invoice', year }, + }); + + if (existing) { + const nextNum = (existing.last_number ?? 0) + 1; + await tx.number_sequences.update({ + where: { id: existing.id }, + data: { last_number: nextNum }, + }); + return nextNum; + } + + await tx.number_sequences.create({ + data: { type: 'invoice', year, last_number: 1 }, + }); + return 1; + }); +} diff --git a/src/utils/sequence.ts b/src/utils/sequence.ts deleted file mode 100644 index e3afe01..0000000 --- a/src/utils/sequence.ts +++ /dev/null @@ -1,46 +0,0 @@ -import prisma from '../config/database'; - -/** - * Atomically get and increment the next sequence number for a document type and year. - * Uses the `number_sequences` table with upsert to avoid race conditions. - */ -export async function nextSequenceNumber(type: string, year: number): Promise { - // Use a transaction with a raw query to atomically increment - const result = await prisma.$queryRaw>` - INSERT INTO number_sequences (type, year, last_number) - VALUES (${type}, ${year}, 1) - ON DUPLICATE KEY UPDATE last_number = last_number + 1; - SELECT last_number FROM number_sequences WHERE type = ${type} AND year = ${year}; - `; - - // $queryRaw with multiple statements may not work on all drivers, use a transaction fallback - return result[0]?.last_number ?? 1; -} - -/** - * Atomically get and increment the next sequence number using Prisma transaction. - * Compatible with all Prisma drivers. - */ -export async function getNextNumber(type: string, year: number): Promise { - return prisma.$transaction(async (tx) => { - // Try to find existing sequence - const existing = await tx.number_sequences.findFirst({ - where: { type, year }, - }); - - if (existing) { - const nextNum = (existing.last_number ?? 0) + 1; - await tx.number_sequences.update({ - where: { id: existing.id }, - data: { last_number: nextNum }, - }); - return nextNum; - } - - // Create new sequence - await tx.number_sequences.create({ - data: { type, year, last_number: 1 }, - }); - return 1; - }); -}