diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts index 7452eb1..3a3436b 100644 --- a/src/routes/admin/quotations.ts +++ b/src/routes/admin/quotations.ts @@ -1,75 +1,39 @@ import { FastifyInstance } from 'fastify'; -import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; 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'; +import { + listOffers, + getOffer, + createOffer, + updateOffer, + deleteOffer, + duplicateOffer, + invalidateOffer, + getNextOfferNumber, +} from '../../services/offers.service'; -interface QuotationItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number } -interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number } - -const ALLOWED_SORT_FIELDS = ['id', 'quotation_number', 'project_code', 'created_at', 'valid_until', 'currency', 'status']; - export default async function quotationsRoutes(fastify: FastifyInstance): Promise { fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, sort, order, search } = parsePagination(query); - const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id'; - const where: Record = {}; - if (query.status) where.status = String(query.status); - if (query.customer_id) where.customer_id = Number(query.customer_id); - if (search) { - where.OR = [ - { quotation_number: { contains: search } }, - { project_code: { contains: search } }, - { customers: { name: { contains: search } } }, - ]; - } - - const [quotations, total] = await Promise.all([ - prisma.quotations.findMany({ - where, - skip, - take: limit, - orderBy: { [sortField]: order }, - include: { - customers: { select: { id: true, name: true } }, - quotation_items: { orderBy: { position: 'asc' } }, - scope_sections: { orderBy: { position: 'asc' } }, - }, - }), - prisma.quotations.count({ where }), - ]); - - // Compute totals and map relation names - const enriched = quotations.map(q => { - const subtotal = q.quotation_items - .filter(i => i.is_included_in_total !== false) - .reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0); - const vatAmount = q.apply_vat ? subtotal * ((Number(q.vat_rate) || 21) / 100) : 0; - const { quotation_items, scope_sections, ...rest } = q; - return { - ...rest, - items: quotation_items, - sections: scope_sections, - customer_name: q.customers?.name || null, - subtotal: Math.round(subtotal * 100) / 100, - vat_amount: Math.round(vatAmount * 100) / 100, - total: Math.round((subtotal + vatAmount) * 100) / 100, - }; + const result = await listOffers({ + page, limit, skip, sort, order, search, + status: query.status ? String(query.status) : undefined, + customer_id: query.customer_id ? Number(query.customer_id) : undefined, }); - return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) }); + return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, page, limit) }); }); // GET /api/admin/offers/next-number fastify.get('/next-number', { preHandler: requirePermission('offers.create') }, async (_request, reply) => { - const number = await generateOfferNumber(); + const number = await getNextOfferNumber(); return success(reply, { number, next_number: number }); }); @@ -77,70 +41,22 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis fastify.post<{ Params: { id: string } }>('/:id/duplicate', { preHandler: requirePermission('offers.create') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const original = await prisma.quotations.findUnique({ - where: { id }, - include: { quotation_items: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: 'asc' } } }, - }); - if (!original) return error(reply, 'Nabídka nenalezena', 404); - const nextOfferNumber = await generateOfferNumber(); + const result = await duplicateOffer(id); + if (!result) return error(reply, 'Nabídka nenalezena', 404); - const copy = await prisma.quotations.create({ - data: { - quotation_number: nextOfferNumber, - project_code: original.project_code, - customer_id: original.customer_id, - valid_until: null, - currency: original.currency, - language: original.language, - vat_rate: original.vat_rate, - apply_vat: original.apply_vat, - exchange_rate: original.exchange_rate, - status: 'active', - scope_title: original.scope_title, - scope_description: original.scope_description, - }, - }); - - if (original.quotation_items.length > 0) { - await prisma.quotation_items.createMany({ - data: original.quotation_items.map((item) => ({ - quotation_id: copy.id, - description: item.description, - item_description: item.item_description, - quantity: item.quantity, - unit: item.unit, - unit_price: item.unit_price, - is_included_in_total: item.is_included_in_total, - position: item.position, - })), - }); - } - - if (original.scope_sections.length > 0) { - await prisma.scope_sections.createMany({ - data: original.scope_sections.map((s) => ({ - quotation_id: copy.id, - title: s.title, - title_cz: s.title_cz, - content: s.content, - position: s.position, - })), - }); - } - - await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: copy.id, description: `Duplikována nabídka ${original.quotation_number} → ${copy.quotation_number}` }); - return success(reply, { id: copy.id, quotation_number: copy.quotation_number }, 201, 'Nabídka byla duplikována'); + await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: result.copy.id, description: `Duplikována nabídka ${result.original.quotation_number} → ${result.copy.quotation_number}` }); + return success(reply, { id: result.copy.id, quotation_number: result.copy.quotation_number }, 201, 'Nabídka byla duplikována'); }); // POST /api/admin/offers/:id/invalidate fastify.post<{ Params: { id: string } }>('/:id/invalidate', { preHandler: requirePermission('offers.edit') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const existing = await prisma.quotations.findUnique({ where: { id } }); + + const existing = await invalidateOffer(id); if (!existing) return error(reply, 'Nabídka nenalezena', 404); - await prisma.quotations.update({ where: { id }, data: { status: 'invalidated', modified_at: new Date() } }); await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Zneplatněna nabídka ${existing.quotation_number}` }); return success(reply, null, 200, 'Nabídka zneplatněna'); }); @@ -148,85 +64,18 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const quotation = await prisma.quotations.findUnique({ - where: { id }, - include: { - customers: true, - quotation_items: { orderBy: { position: 'asc' } }, - scope_sections: { orderBy: { position: 'asc' } }, - }, - }); - if (!quotation) return error(reply, 'Nabídka nenalezena', 404); - // Fetch linked order if exists - let orderInfo = null; - if (quotation.order_id) { - const order = await prisma.orders.findUnique({ - where: { id: quotation.order_id }, - select: { id: true, order_number: true, status: true }, - }); - orderInfo = order; - } + const data = await getOffer(id); + if (!data) return error(reply, 'Nabídka nenalezena', 404); - const { quotation_items, scope_sections, ...rest } = quotation; - return success(reply, { - ...rest, - items: quotation_items, - sections: scope_sections, - customer: quotation.customers, - customer_name: quotation.customers?.name || null, - order: orderInfo, - }); + return success(reply, data); }); fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => { const parsed = parseBody(CreateQuotationSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); - const body = parsed.data; - const quotation = await prisma.quotations.create({ - data: { - quotation_number: body.quotation_number ? String(body.quotation_number) : null, - project_code: body.project_code ? String(body.project_code) : null, - customer_id: body.customer_id ? Number(body.customer_id) : null, - valid_until: body.valid_until ? new Date(String(body.valid_until)) : null, - currency: body.currency ? String(body.currency) : 'CZK', - language: body.language ? String(body.language) : 'cs', - vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0, - apply_vat: body.apply_vat !== false, - exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0, - status: body.status ? String(body.status) : 'active', - scope_title: body.scope_title ? String(body.scope_title) : null, - scope_description: body.scope_description ? String(body.scope_description) : null, - }, - }); - - if (Array.isArray(body.items)) { - await prisma.quotation_items.createMany({ - data: (body.items as QuotationItemInput[]).map((item, i) => ({ - quotation_id: quotation.id, - description: item.description ?? null, - item_description: item.item_description ?? null, - quantity: item.quantity ?? 1, - unit: item.unit ?? null, - unit_price: item.unit_price ?? 0, - is_included_in_total: item.is_included_in_total !== false, - position: item.position ?? i, - })), - }); - } - - if (Array.isArray(body.sections)) { - await prisma.scope_sections.createMany({ - data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ - quotation_id: quotation.id, - title: s.title ?? null, - title_cz: s.title_cz ?? null, - content: s.content ?? null, - position: s.position ?? i, - })), - }); - } + const quotation = await createOffer(parsed.data); await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: quotation.id, description: `Vytvořena nabídka ${quotation.quotation_number}` }); return success(reply, { id: quotation.id }, 201, 'Nabídka byla vytvořena'); @@ -237,74 +86,24 @@ export default async function quotationsRoutes(fastify: FastifyInstance): Promis if (id === null) return; const parsed = parseBody(UpdateQuotationSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); - const body = parsed.data; - const existing = await prisma.quotations.findUnique({ where: { id } }); - if (!existing) return error(reply, 'Nabídka nenalezena', 404); - if (existing.status === 'invalidated') return error(reply, 'Nelze upravit zneplatněnou nabídku', 400); - - await prisma.quotations.update({ - where: { id }, - data: { - quotation_number: body.quotation_number !== undefined ? String(body.quotation_number) : undefined, - customer_id: body.customer_id !== undefined ? Number(body.customer_id) : undefined, - valid_until: body.valid_until !== undefined ? (body.valid_until ? new Date(String(body.valid_until)) : null) : undefined, - currency: body.currency !== undefined ? String(body.currency) : undefined, - language: body.language !== undefined ? String(body.language) : undefined, - vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined, - apply_vat: body.apply_vat !== undefined ? (body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1') : undefined, - exchange_rate: body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined, - status: body.status !== undefined ? String(body.status) : undefined, - project_code: body.project_code !== undefined ? (body.project_code ? String(body.project_code) : null) : undefined, - scope_title: body.scope_title !== undefined ? (body.scope_title ? String(body.scope_title) : null) : undefined, - scope_description: body.scope_description !== undefined ? (body.scope_description ? String(body.scope_description) : null) : undefined, - modified_at: new Date(), - }, - }); - - if (Array.isArray(body.items) || Array.isArray(body.sections)) { - await prisma.$transaction(async (tx) => { - if (Array.isArray(body.items)) { - await tx.quotation_items.deleteMany({ where: { quotation_id: id } }); - await tx.quotation_items.createMany({ - data: (body.items as QuotationItemInput[]).map((item, i) => ({ - quotation_id: id, - description: item.description ?? null, - item_description: item.item_description ?? null, - quantity: item.quantity ?? 1, - unit: item.unit ?? null, - unit_price: item.unit_price ?? 0, - is_included_in_total: item.is_included_in_total !== false, - position: item.position ?? i, - })), - }); - } - if (Array.isArray(body.sections)) { - await tx.scope_sections.deleteMany({ where: { quotation_id: id } }); - await tx.scope_sections.createMany({ - data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ - quotation_id: id, - title: s.title ?? null, - title_cz: s.title_cz ?? null, - content: s.content ?? null, - position: s.position ?? i, - })), - }); - } - }); + const result = await updateOffer(id, parsed.data); + if ('error' in result) { + if (result.error === 'not_found') return error(reply, 'Nabídka nenalezena', 404); + if (result.error === 'invalidated') return error(reply, 'Nelze upravit zneplatněnou nabídku', 400); } - await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Upravena nabídka ${existing.quotation_number}` }); + await logAudit({ request, authData: request.authData, action: 'update', entityType: 'quotation', entityId: id, description: `Upravena nabídka ${(result as any).quotation_number}` }); return success(reply, { id }, 200, 'Nabídka byla uložena'); }); fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.delete') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const existing = await prisma.quotations.findUnique({ where: { id } }); + + const existing = await deleteOffer(id); if (!existing) return error(reply, 'Nabídka nenalezena', 404); - await prisma.quotations.delete({ where: { id } }); await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'quotation', entityId: id, description: `Smazána nabídka ${existing.quotation_number}` }); return success(reply, null, 200, 'Nabídka smazána'); }); diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts new file mode 100644 index 0000000..a74ef2f --- /dev/null +++ b/src/services/offers.service.ts @@ -0,0 +1,284 @@ +import prisma from '../config/database'; +import { generateOfferNumber } from './numbering.service'; + +interface QuotationItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number } +interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number } + +// Re-export for convenience +export { generateOfferNumber as getNextOfferNumber } from './numbering.service'; + +const ALLOWED_SORT_FIELDS = ['id', 'quotation_number', 'project_code', 'created_at', 'valid_until', 'currency', 'status']; + +interface ListOffersParams { + page: number; + limit: number; + skip: number; + sort: string; + order: 'asc' | 'desc'; + search: string; + status?: string; + customer_id?: number; +} + +function enrichQuotation(q: any) { + const subtotal = q.quotation_items + .filter((i: any) => i.is_included_in_total !== false) + .reduce((s: number, i: any) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0); + const vatAmount = q.apply_vat ? subtotal * ((Number(q.vat_rate) || 21) / 100) : 0; + const { quotation_items, scope_sections, ...rest } = q; + return { + ...rest, + items: quotation_items, + sections: scope_sections, + customer_name: q.customers?.name || null, + subtotal: Math.round(subtotal * 100) / 100, + vat_amount: Math.round(vatAmount * 100) / 100, + total: Math.round((subtotal + vatAmount) * 100) / 100, + }; +} + +export async function listOffers(params: ListOffersParams) { + const { page, limit, skip, sort, order, search, status, customer_id } = params; + const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id'; + + const where: Record = {}; + if (status) where.status = status; + if (customer_id) where.customer_id = customer_id; + if (search) { + where.OR = [ + { quotation_number: { contains: search } }, + { project_code: { contains: search } }, + { customers: { name: { contains: search } } }, + ]; + } + + const [quotations, total] = await Promise.all([ + prisma.quotations.findMany({ + where, + skip, + take: limit, + orderBy: { [sortField]: order }, + include: { + customers: { select: { id: true, name: true } }, + quotation_items: { orderBy: { position: 'asc' } }, + scope_sections: { orderBy: { position: 'asc' } }, + }, + }), + prisma.quotations.count({ where }), + ]); + + const enriched = quotations.map(enrichQuotation); + + return { data: enriched, total, page, limit }; +} + +export async function getOffer(id: number) { + const quotation = await prisma.quotations.findUnique({ + where: { id }, + include: { + customers: true, + quotation_items: { orderBy: { position: 'asc' } }, + scope_sections: { orderBy: { position: 'asc' } }, + }, + }); + if (!quotation) return null; + + // Fetch linked order if exists + let orderInfo = null; + if (quotation.order_id) { + const order = await prisma.orders.findUnique({ + where: { id: quotation.order_id }, + select: { id: true, order_number: true, status: true }, + }); + orderInfo = order; + } + + const { quotation_items, scope_sections, ...rest } = quotation; + return { + ...rest, + items: quotation_items, + sections: scope_sections, + customer: quotation.customers, + customer_name: quotation.customers?.name || null, + order: orderInfo, + }; +} + +export async function createOffer(body: Record) { + const quotation = await prisma.quotations.create({ + data: { + quotation_number: body.quotation_number ? String(body.quotation_number) : null, + project_code: body.project_code ? String(body.project_code) : null, + customer_id: body.customer_id ? Number(body.customer_id) : null, + valid_until: body.valid_until ? new Date(String(body.valid_until)) : null, + currency: body.currency ? String(body.currency) : 'CZK', + language: body.language ? String(body.language) : 'cs', + vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0, + apply_vat: body.apply_vat !== false, + exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0, + status: body.status ? String(body.status) : 'active', + scope_title: body.scope_title ? String(body.scope_title) : null, + scope_description: body.scope_description ? String(body.scope_description) : null, + }, + }); + + if (Array.isArray(body.items)) { + await prisma.quotation_items.createMany({ + data: (body.items as QuotationItemInput[]).map((item, i) => ({ + quotation_id: quotation.id, + description: item.description ?? null, + item_description: item.item_description ?? null, + quantity: item.quantity ?? 1, + unit: item.unit ?? null, + unit_price: item.unit_price ?? 0, + is_included_in_total: item.is_included_in_total !== false, + position: item.position ?? i, + })), + }); + } + + if (Array.isArray(body.sections)) { + await prisma.scope_sections.createMany({ + data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ + quotation_id: quotation.id, + title: s.title ?? null, + title_cz: s.title_cz ?? null, + content: s.content ?? null, + position: s.position ?? i, + })), + }); + } + + return quotation; +} + +export async function updateOffer(id: number, body: Record) { + const existing = await prisma.quotations.findUnique({ where: { id } }); + if (!existing) return { error: 'not_found' as const }; + if (existing.status === 'invalidated') return { error: 'invalidated' as const }; + + await prisma.quotations.update({ + where: { id }, + data: { + quotation_number: body.quotation_number !== undefined ? String(body.quotation_number) : undefined, + customer_id: body.customer_id !== undefined ? Number(body.customer_id) : undefined, + valid_until: body.valid_until !== undefined ? (body.valid_until ? new Date(String(body.valid_until)) : null) : undefined, + currency: body.currency !== undefined ? String(body.currency) : undefined, + language: body.language !== undefined ? String(body.language) : undefined, + vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined, + apply_vat: body.apply_vat !== undefined ? (body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1') : undefined, + exchange_rate: body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined, + status: body.status !== undefined ? String(body.status) : undefined, + project_code: body.project_code !== undefined ? (body.project_code ? String(body.project_code) : null) : undefined, + scope_title: body.scope_title !== undefined ? (body.scope_title ? String(body.scope_title) : null) : undefined, + scope_description: body.scope_description !== undefined ? (body.scope_description ? String(body.scope_description) : null) : undefined, + modified_at: new Date(), + }, + }); + + if (Array.isArray(body.items) || Array.isArray(body.sections)) { + await prisma.$transaction(async (tx) => { + if (Array.isArray(body.items)) { + await tx.quotation_items.deleteMany({ where: { quotation_id: id } }); + await tx.quotation_items.createMany({ + data: (body.items as QuotationItemInput[]).map((item, i) => ({ + quotation_id: id, + description: item.description ?? null, + item_description: item.item_description ?? null, + quantity: item.quantity ?? 1, + unit: item.unit ?? null, + unit_price: item.unit_price ?? 0, + is_included_in_total: item.is_included_in_total !== false, + position: item.position ?? i, + })), + }); + } + if (Array.isArray(body.sections)) { + await tx.scope_sections.deleteMany({ where: { quotation_id: id } }); + await tx.scope_sections.createMany({ + data: (body.sections as ScopeSectionInput[]).map((s, i) => ({ + quotation_id: id, + title: s.title ?? null, + title_cz: s.title_cz ?? null, + content: s.content ?? null, + position: s.position ?? i, + })), + }); + } + }); + } + + return { id, quotation_number: existing.quotation_number }; +} + +export async function deleteOffer(id: number) { + const existing = await prisma.quotations.findUnique({ where: { id } }); + if (!existing) return null; + + await prisma.quotations.delete({ where: { id } }); + return existing; +} + +export async function duplicateOffer(id: number) { + const original = await prisma.quotations.findUnique({ + where: { id }, + include: { quotation_items: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: 'asc' } } }, + }); + if (!original) return null; + + const nextOfferNumber = await generateOfferNumber(); + + const copy = await prisma.quotations.create({ + data: { + quotation_number: nextOfferNumber, + project_code: original.project_code, + customer_id: original.customer_id, + valid_until: null, + currency: original.currency, + language: original.language, + vat_rate: original.vat_rate, + apply_vat: original.apply_vat, + exchange_rate: original.exchange_rate, + status: 'active', + scope_title: original.scope_title, + scope_description: original.scope_description, + }, + }); + + if (original.quotation_items.length > 0) { + await prisma.quotation_items.createMany({ + data: original.quotation_items.map((item) => ({ + quotation_id: copy.id, + description: item.description, + item_description: item.item_description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + is_included_in_total: item.is_included_in_total, + position: item.position, + })), + }); + } + + if (original.scope_sections.length > 0) { + await prisma.scope_sections.createMany({ + data: original.scope_sections.map((s) => ({ + quotation_id: copy.id, + title: s.title, + title_cz: s.title_cz, + content: s.content, + position: s.position, + })), + }); + } + + return { copy, original }; +} + +export async function invalidateOffer(id: number) { + const existing = await prisma.quotations.findUnique({ where: { id } }); + if (!existing) return null; + + await prisma.quotations.update({ where: { id }, data: { status: 'invalidated', modified_at: new Date() } }); + return existing; +}