refactor: extract offers business logic into offers.service.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,75 +1,39 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import prisma from '../../config/database';
|
|
||||||
import { requirePermission } from '../../middleware/auth';
|
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 { 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';
|
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<void> {
|
export default async function quotationsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
fastify.get('/', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
const { page, limit, skip, sort, order, search } = parsePagination(query);
|
||||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
|
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const result = await listOffers({
|
||||||
if (query.status) where.status = String(query.status);
|
page, limit, skip, sort, order, search,
|
||||||
if (query.customer_id) where.customer_id = Number(query.customer_id);
|
status: query.status ? String(query.status) : undefined,
|
||||||
if (search) {
|
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// 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 number = await generateOfferNumber();
|
const number = await getNextOfferNumber();
|
||||||
return success(reply, { number, next_number: number });
|
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) => {
|
fastify.post<{ Params: { id: string } }>('/:id/duplicate', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
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({
|
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}` });
|
||||||
data: {
|
return success(reply, { id: result.copy.id, quotation_number: result.copy.quotation_number }, 201, 'Nabídka byla duplikována');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/admin/offers/:id/invalidate
|
// POST /api/admin/offers/:id/invalidate
|
||||||
fastify.post<{ Params: { id: string } }>('/:id/invalidate', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
fastify.post<{ Params: { id: string } }>('/:id/invalidate', { preHandler: requirePermission('offers.edit') }, async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
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);
|
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}` });
|
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');
|
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) => {
|
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.view') }, async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
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
|
const data = await getOffer(id);
|
||||||
let orderInfo = null;
|
if (!data) return error(reply, 'Nabídka nenalezena', 404);
|
||||||
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 success(reply, data);
|
||||||
return success(reply, {
|
|
||||||
...rest,
|
|
||||||
items: quotation_items,
|
|
||||||
sections: scope_sections,
|
|
||||||
customer: quotation.customers,
|
|
||||||
customer_name: quotation.customers?.name || null,
|
|
||||||
order: orderInfo,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
|
fastify.post('/', { preHandler: requirePermission('offers.create') }, async (request, reply) => {
|
||||||
const parsed = parseBody(CreateQuotationSchema, request.body);
|
const parsed = parseBody(CreateQuotationSchema, request.body);
|
||||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||||
const body = parsed.data;
|
|
||||||
|
|
||||||
const quotation = await prisma.quotations.create({
|
const quotation = await createOffer(parsed.data);
|
||||||
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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'quotation', entityId: quotation.id, description: `Vytvořena nabídka ${quotation.quotation_number}` });
|
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');
|
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;
|
if (id === null) return;
|
||||||
const parsed = parseBody(UpdateQuotationSchema, request.body);
|
const parsed = parseBody(UpdateQuotationSchema, request.body);
|
||||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||||
const body = parsed.data;
|
|
||||||
|
|
||||||
const existing = await prisma.quotations.findUnique({ where: { id } });
|
const result = await updateOffer(id, parsed.data);
|
||||||
if (!existing) return error(reply, 'Nabídka nenalezena', 404);
|
if ('error' in result) {
|
||||||
if (existing.status === 'invalidated') return error(reply, 'Nelze upravit zneplatněnou nabídku', 400);
|
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 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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
return success(reply, { id }, 200, 'Nabídka byla uložena');
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.delete') }, async (request, reply) => {
|
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('offers.delete') }, async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
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);
|
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}` });
|
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');
|
return success(reply, null, 200, 'Nabídka smazána');
|
||||||
});
|
});
|
||||||
|
|||||||
284
src/services/offers.service.ts
Normal file
284
src/services/offers.service.ts
Normal file
@@ -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<string, unknown> = {};
|
||||||
|
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<string, any>) {
|
||||||
|
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<string, any>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user