refactor: extract numbering logic into numbering.service.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 09:00:07 +01:00
parent d2b22e9399
commit 2146696bc6
6 changed files with 86 additions and 127 deletions

View File

@@ -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 });
});

View File

@@ -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<string, string[]> = {
stornovana: [],
};
// Shared number generator matching PHP generateSharedNumber()
// Format: YYtypeCode + 4-digit sequence, shared between orders and projects
async function generateSharedNumber(): Promise<string> {
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<string> {
return generateSharedNumber();
}
async function generateProjectNumber(): Promise<string> {
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<vo
// GET /api/admin/orders/next-number
fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => {
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<vo
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
const orderNumber = await generateOrderNumber();
const projectNumber = await generateProjectNumber();
const orderNumber = await generateSharedNumber();
const projectNumber = await generateSharedNumber();
const result = await prisma.$transaction(async (tx) => {
// Create the order
@@ -301,8 +271,8 @@ export default async function ordersRoutes(fastify: FastifyInstance): Promise<vo
if (!quotation) return error(reply, 'Nabídka nenalezena', 404);
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
const orderNumber = await generateOrderNumber();
const projectNumber = await generateProjectNumber();
const orderNumber = await generateSharedNumber();
const projectNumber = await generateSharedNumber();
const result = await prisma.$transaction(async (tx) => {
const order = await tx.orders.create({

View File

@@ -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

View File

@@ -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,