import * as db from '../../db/quote.js'; import * as orderDb from '../../db/order.js'; import { createAndAttachManifestPdf } from '../../services/manifestPdf.js'; import { emit } from '../sse.js'; // #region Helpers function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } function quoteErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'No entity selected') return 400; if (error.message === 'Entity required') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Quote not found') return 404; if (error.message === 'Order not found') return 404; if (error.message === 'Manifest not found') return 404; if (error.message === 'Attachment not found') return 404; if (error.message === 'Entity not found') return 404; if (error.message === 'Article not found') return 404; if (error.message === 'Invalid entityId') return 400; if (error.message === 'Quote number already exists') return 409; if (error.message === 'Invalid quote') return 400; if (error.message === 'Invalid order') return 400; if (error.message === 'Invalid lines') return 400; if (error.message === 'File name required') return 400; if (error.message === 'Mimetype required') return 400; if (error.message === 'Content required') return 400; if (error.message === 'Invalid attachment order') return 400; if (error.message?.includes('Invalid target id')) return 400; if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400; return 500; } // #endregion /** * Registers quote routes for the authenticated user's selected company. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /quote: * get: * tags: [Quote] * summary: List quotes * description: | * Lists quotes (`manifest` rows with `type = 'quote'`) * for the authenticated user's selected company. * security: * - cookieAuth: [] * parameters: * - in: query * name: offset * schema: * type: integer * default: 0 * minimum: 0 * description: Number of quotes to skip * - in: query * name: limit * schema: * type: integer * default: 50 * minimum: 1 * maximum: 1000 * description: Number of quotes to return * - in: query * name: search * schema: * type: string * description: Free-text search matching quote number, external number, or customer name * - in: query * name: seekMonths * schema: * type: integer * description: Negative integer for cutoff months ago (e.g. -1 for quotes older than 1 month) * x-markdown: quote/list.md * responses: * 200: * description: Quote list * content: * application/json: * schema: * type: object * required: [quotes, total, unfilteredTotal, offset, limit] * properties: * quotes: * type: array * items: * $ref: '#/components/schemas/Quote' * total: * type: integer * description: Total number of filtered quotes * unfilteredTotal: * type: integer * description: Total number of quotes without any filters applied * offset: * type: integer * description: Number of quotes skipped * limit: * type: integer * description: Number of quotes returned * 400: * description: Invalid query parameters, invalid offset, invalid limit, or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * post: * tags: [Quote] * summary: Create quote * description: | * Creates a quote with header and lines in one transaction. * `date` is the document date (valid-until). * `entityId` must be a company customer (not Lieferanten), or null. * Each line requires `sku` or `name`, `amount`, `net`, and `taxrate` (rate); `pu` defaults to `1`; `rebate` defaults to `0`. * security: * - cookieAuth: [] * x-markdown: quote/create.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [number, date, lines] * properties: * number: * type: string * date: * type: string * format: date-time * description: Document date / valid-until * entityId: * type: integer * nullable: true * description: Customer entity (manifest.entity_id) * externalNumber: * type: string * nullable: true * description: External customer reference number (manifest.external_number) * lines: * type: array * items: * $ref: '#/components/schemas/ManifestLineInput' * example: * number: Q-2026-001 * date: '2026-08-31' * entityId: 1 * lines: * - sku: '1' * name: 'Sample Article' * pu: 1 * amount: 10 * net: 99.5 * taxrate: 19 * responses: * 201: * description: Created quote * content: * application/json: * schema: * type: object * properties: * quote: * $ref: '#/components/schemas/Quote' * 400: * description: Invalid quote, invalid lines, or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Company, entity, or article not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /quote app.get(`${basePath}/quote`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const rawOffset = req.query.offset; const rawLimit = req.query.limit; let offset = 0; let limit = 50; if (rawOffset !== undefined) { offset = parseInt(rawOffset, 10); if (!Number.isInteger(offset) || offset < 0) { return res.status(400).json({ error: 'offset must be an integer >= 0' }); } } if (rawLimit !== undefined) { limit = parseInt(rawLimit, 10); if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' }); } } let entityId = undefined; if (req.query.entityId !== undefined && req.query.entityId !== '') { const parsedEntity = parseInt(req.query.entityId, 10); if (!Number.isInteger(parsedEntity) || parsedEntity <= 0) { return res.status(400).json({ error: 'entityId must be a positive integer' }); } entityId = parsedEntity; } const search = typeof req.query.search === 'string' && req.query.search.trim() !== '' ? req.query.search.trim() : undefined; let seekMonths = undefined; if (req.query.seekMonths !== undefined && req.query.seekMonths !== '') { const parsedSeek = parseInt(req.query.seekMonths, 10); if (!Number.isInteger(parsedSeek)) { return res.status(400).json({ error: 'seekMonths must be an integer' }); } seekMonths = parsedSeek; } try { const result = await db.list(req.user.id, { offset, limit, search, entityId, seekMonths, }); res.status(200).json(result); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('List quotes failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region POST /quote app.post(`${basePath}/quote`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const { number, date, createdAt, entityId, externalNumber, lines } = req.body ?? {}; if (entityId !== undefined && entityId !== null && (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0)) { return res.status(400).json({ error: 'Invalid entityId' }); } try { const quote = await db.create(req.user.id, { number, date, createdAt, entityId, externalNumber: externalNumber !== undefined && externalNumber !== null ? String(externalNumber) : externalNumber, lines, }); res.status(201).json({ quote }); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Create quote failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /quote/{id}: * get: * tags: [Quote] * summary: Get quote * description: Returns a quote with lines for the selected company. * security: * - cookieAuth: [] * x-markdown: quote/get.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Quote * content: * application/json: * schema: * type: object * properties: * quote: * $ref: '#/components/schemas/Quote' * 400: * description: No company selected or invalid id * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Quote or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * patch: * tags: [Quote] * summary: Update quote * description: | * Patches header fields that are present. If `lines` is present, replaces * all lines; if omitted, leaves lines unchanged. Returns the full quote. * `date` is the document date (valid-until). * security: * - cookieAuth: [] * x-markdown: quote/update.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * number: * type: string * date: * type: string * format: date-time * description: Document date / valid-until * entityId: * type: integer * nullable: true * externalNumber: * type: string * nullable: true * description: External customer reference number (manifest.external_number) * lines: * type: array * items: * $ref: '#/components/schemas/ManifestLineInput' * example: * number: Q-2026-001b * lines: [] * responses: * 200: * description: Updated quote * content: * application/json: * schema: * type: object * properties: * quote: * $ref: '#/components/schemas/Quote' * 400: * description: Invalid quote, invalid lines, invalid id, or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Quote, entity, article, or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Quote] * summary: Delete quote * description: Deletes the quote (`manifestarticle` rows cascade). * security: * - cookieAuth: [] * x-markdown: quote/delete.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 204: * description: Deleted * 400: * description: No company selected or invalid id * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Quote not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /quote/:id app.get(`${basePath}/quote/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } try { const quote = await db.getById(req.user.id, id); res.status(200).json({ quote }); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Get quote failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region PATCH /quote/:id app.patch(`${basePath}/quote/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } const body = req.body ?? {}; const patch = {}; if (Object.prototype.hasOwnProperty.call(body, 'number')) { patch.number = body.number; } if (Object.prototype.hasOwnProperty.call(body, 'date')) { patch.date = body.date; } if (Object.prototype.hasOwnProperty.call(body, 'createdAt')) { patch.createdAt = body.createdAt; } if (Object.prototype.hasOwnProperty.call(body, 'entityId')) { if (body.entityId !== null && (typeof body.entityId !== 'number' || !Number.isInteger(body.entityId) || body.entityId <= 0)) { return res.status(400).json({ error: 'Invalid entityId' }); } patch.entityId = body.entityId; } if (Object.prototype.hasOwnProperty.call(body, 'externalNumber')) { patch.externalNumber = body.externalNumber !== null && body.externalNumber !== undefined ? String(body.externalNumber) : null; } if (Object.prototype.hasOwnProperty.call(body, 'lines')) { patch.lines = body.lines; } try { const quote = await db.update(req.user.id, id, patch); res.status(200).json({ quote }); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Update quote failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region DELETE /quote/:id app.delete(`${basePath}/quote/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } try { await db.remove(req.user.id, id); res.sendStatus(204); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Delete quote failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /quote/{id}/order: * post: * tags: [Quote] * summary: Create order from quote * description: | * Creates a sales order from an existing quote for the authenticated user's selected company. * Copies lines and customer entity from the quote. * Sets external reference (externalNumber) to the quote number unless explicitly overridden. * If the order number is omitted, it is auto-allocated from the sequence. * Fails with 400 (No entity selected) if neither the quote nor the request body specifies a customer entity. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Quote ID * requestBody: * required: false * content: * application/json: * schema: * type: object * properties: * number: * type: string * description: Optional sales order number. Auto-allocated from sequence if omitted. * date: * type: string * description: Optional order date (ISO format). Defaults to current date. * createdAt: * type: string * format: date-time * description: Optional creation timestamp. * entityId: * type: integer * nullable: true * description: Customer entity ID override. Defaults to quote's entityId. * externalNumber: * type: string * nullable: true * description: External customer reference override. Defaults to quote's number. * lines: * type: array * description: Optional line items override. Defaults to quote's lines. * items: * $ref: '#/components/schemas/ManifestLineInput' * x-markdown: quote/order.md * responses: * 201: * description: Created order * content: * application/json: * schema: * type: object * properties: * order: * $ref: '#/components/schemas/Order' * 400: * description: No company selected, no entity selected, invalid quote id, or invalid order data * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized for company * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Quote not found or entity not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /quote/:id/order app.post(`${basePath}/quote/:id/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } const { number, date, createdAt, entityId, externalNumber, lines } = req.body ?? {}; if (entityId !== undefined && entityId !== null && (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0)) { return res.status(400).json({ error: 'Invalid entityId' }); } try { const order = await orderDb.createFromQuote(req.user.id, id, { number, date, createdAt, entityId, externalNumber: externalNumber !== undefined && externalNumber !== null ? String(externalNumber) : externalNumber, lines, }); res.status(201).json({ order }); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Create order from quote failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /quote/{id}/pdf: * post: * tags: [Quote] * summary: Generate and attach quote PDF * description: | * Generates an A4 PDF document for the quote with prices, net sum, and tax breakdown. * Stores the PDF as a `manifestattachment` for printing or downloading. * security: * - cookieAuth: [] * x-signals: [quote] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Quote ID * responses: * 201: * description: PDF generated and attached to quote * content: * application/json: * schema: * type: object * required: [attachment, fileUrl] * properties: * attachment: * $ref: '#/components/schemas/DeliveryFile' * fileUrl: * type: string * example: /quote/1/file/5 * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Quote not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /quote/:id/pdf app.post(`${basePath}/quote/:id/pdf`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } try { const { attachment } = await createAndAttachManifestPdf( req.user.id, selected.companyId, id, 'quote' ); res.status(201).json({ attachment, fileUrl: `/quote/${id}/file/${attachment.fileId}`, }); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Create quote PDF failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /quote/{id}/file/{fileId}: * get: * tags: [Quote] * summary: Get quote attachment file content * description: | * Returns the full binary content of an attached file (e.g. quote PDF) * for the specified quote. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Quote ID * - in: path * name: fileId * required: true * schema: * type: integer * description: File ID or attachment ID * - in: query * name: download * required: false * schema: * type: boolean * description: When true, sets Content-Disposition to attachment instead of inline * responses: * 200: * description: Binary file content * content: * application/pdf: * schema: * type: string * format: binary * application/octet-stream: * schema: * type: string * format: binary * 304: * description: Not modified * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Quote or file not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /quote/:id/file/:fileId app.get(`${basePath}/quote/:id/file/:fileId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } const fileId = Number(req.params.fileId); if (!Number.isInteger(fileId) || fileId <= 0) { return res.status(400).json({ error: 'fileId is required' }); } try { const attachment = await db.getFile(req.user.id, id, fileId); if (attachment.hash) { const etag = `"${attachment.hash}"`; res.setHeader('ETag', etag); const ifNoneMatch = req.headers['if-none-match']; if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) { return res.status(304).end(); } } const isDownload = req.query.download === '1' || req.query.download === 'true'; const dispositionType = isDownload ? 'attachment' : 'inline'; const filename = attachment.name || `file-${attachment.fileId}`; const asciiName = filename.replace(/["\r\n\\]/g, '_'); const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape); res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream'); res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`); res.setHeader('Cache-Control', 'private, max-age=86400'); if (attachment.content && Buffer.isBuffer(attachment.content)) { res.setHeader('Content-Length', String(attachment.content.length)); return res.status(200).send(attachment.content); } else { const buffer = Buffer.from(attachment.content || ''); res.setHeader('Content-Length', String(buffer.length)); return res.status(200).send(buffer); } } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Get quote file failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /quote/{id}/file/{fileId}: * delete: * tags: [Quote] * summary: Delete quote file attachment * description: | * Removes a file attachment from the quote and deletes the underlying file. * security: * - cookieAuth: [] * x-signals: [quote] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Quote ID * - in: path * name: fileId * required: true * schema: * type: integer * description: File ID or attachment ID * responses: * 204: * description: Attachment deleted * 400: * description: Invalid parameters or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Quote or attachment not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /quote/:id/file/:fileId app.delete(`${basePath}/quote/:id/file/:fileId`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'id is required' }); } const fileId = Number(req.params.fileId); if (!Number.isInteger(fileId) || fileId <= 0) { return res.status(400).json({ error: 'fileId is required' }); } try { await db.removeAttachment(req.user.id, id, fileId); emit('quote', { companyId: selected.companyId }); res.sendStatus(204); } catch (error) { const status = quoteErrorStatus(error); if (status === 500) console.error('Delete quote attachment failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }