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:
@@ -4,7 +4,7 @@ import { requirePermission } from '../../middleware/auth';
|
|||||||
import { logAudit } from '../../services/audit';
|
import { logAudit } from '../../services/audit';
|
||||||
import { success, error, parseId } from '../../utils/response';
|
import { success, error, parseId } from '../../utils/response';
|
||||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||||
import { getNextNumber } from '../../utils/sequence';
|
import { generateInvoiceNumber } from '../../services/numbering.service';
|
||||||
import { parseBody } from '../../schemas/common';
|
import { parseBody } from '../../schemas/common';
|
||||||
import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema';
|
import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema';
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise<
|
|||||||
const prefix = `${yy}${typeCode}`;
|
const prefix = `${yy}${typeCode}`;
|
||||||
|
|
||||||
// Atomic numbering via number_sequences table
|
// 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')}`;
|
const number = `${prefix}${String(nextNum).padStart(4, '0')}`;
|
||||||
return success(reply, { number, next_number: number });
|
return success(reply, { number, next_number: number });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { success, error, parseId } from '../../utils/response';
|
|||||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||||
import { parseBody } from '../../schemas/common';
|
import { parseBody } from '../../schemas/common';
|
||||||
import { CreateOrderFromQuotationSchema, CreateOrderSchema, UpdateOrderSchema } from '../../schemas/orders.schema';
|
import { CreateOrderFromQuotationSchema, CreateOrderSchema, UpdateOrderSchema } from '../../schemas/orders.schema';
|
||||||
|
import { generateSharedNumber } from '../../services/numbering.service';
|
||||||
|
|
||||||
import multipart from '@fastify/multipart';
|
import multipart from '@fastify/multipart';
|
||||||
|
|
||||||
@@ -17,37 +18,6 @@ const VALID_TRANSITIONS: Record<string, string[]> = {
|
|||||||
stornovana: [],
|
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 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 }
|
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
|
// GET /api/admin/orders/next-number
|
||||||
fastify.get('/next-number', { preHandler: requirePermission('orders.create') }, async (_request, reply) => {
|
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 });
|
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) 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);
|
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
|
||||||
|
|
||||||
const orderNumber = await generateOrderNumber();
|
const orderNumber = await generateSharedNumber();
|
||||||
const projectNumber = await generateProjectNumber();
|
const projectNumber = await generateSharedNumber();
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
// Create the order
|
// 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) 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);
|
if (quotation.order_id) return error(reply, 'Z této nabídky již byla vytvořena objednávka', 400);
|
||||||
|
|
||||||
const orderNumber = await generateOrderNumber();
|
const orderNumber = await generateSharedNumber();
|
||||||
const projectNumber = await generateProjectNumber();
|
const projectNumber = await generateSharedNumber();
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const order = await tx.orders.create({
|
const order = await tx.orders.create({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { success, error, parseId } from '../../utils/response';
|
|||||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||||
import { parseBody } from '../../schemas/common';
|
import { parseBody } from '../../schemas/common';
|
||||||
import { CreateProjectSchema, UpdateProjectSchema, CreateProjectNoteSchema } from '../../schemas/projects.schema';
|
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'];
|
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)
|
// GET /api/admin/projects/next-number — shared sequence with orders (matches PHP)
|
||||||
fastify.get('/next-number', { preHandler: requirePermission('projects.create') }, async (_request, reply) => {
|
fastify.get('/next-number', { preHandler: requirePermission('projects.create') }, async (_request, reply) => {
|
||||||
const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
|
const nextNumber = await generateSharedNumber();
|
||||||
const typeCode = settings?.order_type_code || '71';
|
return success(reply, { next_number: nextNumber });
|
||||||
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')}` });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/admin/projects/:id/notes/:noteId
|
// DELETE /api/admin/projects/:id/notes/:noteId
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { success, error, parseId } from '../../utils/response';
|
|||||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||||
import { parseBody } from '../../schemas/common';
|
import { parseBody } from '../../schemas/common';
|
||||||
import { CreateQuotationSchema, UpdateQuotationSchema } from '../../schemas/offers.schema';
|
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 }
|
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
|
// GET /api/admin/offers/next-number
|
||||||
fastify.get('/next-number', { preHandler: requirePermission('offers.create') }, async (_request, reply) => {
|
fastify.get('/next-number', { preHandler: requirePermission('offers.create') }, async (_request, reply) => {
|
||||||
const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
|
const number = await generateOfferNumber();
|
||||||
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')}`;
|
|
||||||
return success(reply, { number, next_number: number });
|
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);
|
if (!original) return error(reply, 'Nabídka nenalezena', 404);
|
||||||
|
|
||||||
// Get next number by querying MAX from existing quotations (matches PHP logic)
|
const nextOfferNumber = await generateOfferNumber();
|
||||||
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 copy = await prisma.quotations.create({
|
const copy = await prisma.quotations.create({
|
||||||
data: {
|
data: {
|
||||||
quotation_number: `${year}/${qPrefix}/${String(nextNum).padStart(3, '0')}`,
|
quotation_number: nextOfferNumber,
|
||||||
project_code: original.project_code,
|
project_code: original.project_code,
|
||||||
customer_id: original.customer_id,
|
customer_id: original.customer_id,
|
||||||
valid_until: null,
|
valid_until: null,
|
||||||
|
|||||||
71
src/services/numbering.service.ts
Normal file
71
src/services/numbering.service.ts
Normal file
@@ -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<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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next offer number. Queries MAX from quotations table.
|
||||||
|
* Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008)
|
||||||
|
*/
|
||||||
|
export async function generateOfferNumber(): Promise<string> {
|
||||||
|
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<number> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<number> {
|
|
||||||
// Use a transaction with a raw query to atomically increment
|
|
||||||
const result = await prisma.$queryRaw<Array<{ last_number: number }>>`
|
|
||||||
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<number> {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user