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