import * as db 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 orderErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Company 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 === 'No entity selected') return 400; if (error.message === 'Order number already exists') return 409; if (error.message === 'Invalid entityId') return 400; if (error.message === 'Invalid order') return 400; if (error.message === 'Invalid lines') return 400; if (error.message === 'Invalid offset') return 400; if (error.message === 'Invalid limit') return 400; if (error.message === 'Invalid seekMonths') 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?.includes('parent article') || error.message?.includes('A parent article')) return 400; return 500; } // #endregion /** * Registers sales-order routes for the authenticated user's selected company. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /order: * get: * tags: [Order] * summary: List orders * description: | * Lists sales orders (`manifest` rows with `type = 'order'`) * for the authenticated user's selected company. * Results are paged, include line items, shipping, payment, and provide total unpaged count as well as unfiltered count. * security: * - cookieAuth: [] * parameters: * - in: query * name: offset * schema: * type: integer * default: 0 * minimum: 0 * description: Number of orders to skip * - in: query * name: limit * schema: * type: integer * default: 50 * minimum: 1 * maximum: 1000 * description: Number of orders to return * - in: query * name: search * schema: * type: string * description: Free-text search matching order number, external number, or customer name * - in: query * name: onlyUnpaid * schema: * type: boolean * default: false * description: When true, returns only unpaid orders (including overpaid orders) * - in: query * name: onlyUndelivered * schema: * type: boolean * default: false * description: When true, returns only undelivered orders * - in: query * name: seekMonths * schema: * type: integer * description: Negative integer for cutoff months ago (e.g. -1 for orders older than 1 month) * x-markdown: order/list.md * responses: * 200: * description: Order list * content: * application/json: * schema: * type: object * required: [orders, total, unfilteredTotal, offset, limit] * properties: * orders: * type: array * items: * $ref: '#/components/schemas/Order' * total: * type: integer * description: Total number of filtered orders * unfilteredTotal: * type: integer * description: Total number of orders without any filters applied * offset: * type: integer * description: Number of orders skipped * limit: * type: integer * description: Number of orders 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: [Order] * summary: Create order * description: | * Creates a sales order with header and lines in one transaction. * `date` is the document date (expected delivery). * `entityId` must be a company customer (not Lieferanten). * Each line requires `sku` or `name`, `amount`, `net`, and `taxrate` (rate); `pu` defaults to `1`; `rebate` defaults to `0`; `shippable` defaults to `true` (`false` = not shippable, excluded from order completeness). * security: * - cookieAuth: [] * x-markdown: order/create.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [number, date, entityId, lines] * properties: * number: * type: string * date: * type: string * format: date-time * description: Document date / expected delivery * entityId: * type: integer * 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: O-2026-001 * date: '2026-09-15' * entityId: 1 * lines: * - sku: '1' * name: 'Sample Article' * pu: 1 * amount: 10 * net: 99.5 * taxrate: 19 * responses: * 201: * description: Created order * content: * application/json: * schema: * type: object * properties: * order: * $ref: '#/components/schemas/Order' * 400: * description: Invalid order, 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 /order app.get(`${basePath}/order`, 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' }); } } const search = typeof req.query.search === 'string' && req.query.search.trim() !== '' ? req.query.search.trim() : undefined; const onlyUnpaid = req.query.onlyUnpaid === 'true'; const onlyUndelivered = req.query.onlyUndelivered === 'true'; 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; } 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, onlyUnpaid: onlyUnpaid ? true : undefined, onlyUndelivered: onlyUndelivered ? true : undefined, entityId, seekMonths, }); res.status(200).json(result); } catch (error) { const status = orderErrorStatus(error); if (status === 500) console.error('List orders failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region POST /order app.post(`${basePath}/order`, 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) { return res.status(400).json({ error: 'No entity selected' }); } if (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0) { return res.status(400).json({ error: 'Invalid entityId' }); } try { const order = await db.create(req.user.id, { number, date, createdAt, entityId, externalNumber: externalNumber !== undefined && externalNumber !== null ? String(externalNumber) : externalNumber, lines, }); res.status(201).json({ order }); } catch (error) { const status = orderErrorStatus(error); if (status === 500) console.error('Create order failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /order/{id}: * get: * tags: [Order] * summary: Get order * description: Returns a sales order with lines for the selected company. * security: * - cookieAuth: [] * x-markdown: order/get.md * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Order * content: * application/json: * schema: * type: object * properties: * order: * $ref: '#/components/schemas/Order' * 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: Order or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * patch: * tags: [Order] * summary: Update order * description: | * Patches header fields that are present. If `lines` is present, replaces * all lines; if omitted, leaves lines unchanged. Returns the full order. * `date` is the document date (expected delivery). * security: * - cookieAuth: [] * x-markdown: order/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 / expected delivery * 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: O-2026-001b * lines: [] * responses: * 200: * description: Updated order * content: * application/json: * schema: * type: object * properties: * order: * $ref: '#/components/schemas/Order' * 400: * description: Invalid order, 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: Order, entity, article, or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Order] * summary: Delete order * description: Deletes the sales order (`manifestarticle` rows cascade). * security: * - cookieAuth: [] * x-markdown: order/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: Order not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /order/:id app.get(`${basePath}/order/: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 order = await db.getById(req.user.id, id); res.status(200).json({ order }); } catch (error) { const status = orderErrorStatus(error); if (status === 500) console.error('Get order failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region PATCH /order/:id app.patch(`${basePath}/order/: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) { return res.status(400).json({ error: 'No entity selected' }); } if (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 order = await db.update(req.user.id, id, patch); res.status(200).json({ order }); } catch (error) { const status = orderErrorStatus(error); if (status === 500) console.error('Update order failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion // #region DELETE /order/:id app.delete(`${basePath}/order/: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 = orderErrorStatus(error); if (status === 500) console.error('Delete order failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /order/{id}/pdf: * post: * tags: [Order] * summary: Generate and attach order PDF * description: | * Generates an A4 PDF document for the sales order with line items, prices, and tax breakdown. * Attaches the PDF as a `manifestattachment` to the order so it can be printed or archived. * security: * - cookieAuth: [] * x-signals: [order] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Order ID * responses: * 201: * description: PDF generated and attached to order * content: * application/json: * schema: * type: object * required: [attachment, fileUrl] * properties: * attachment: * $ref: '#/components/schemas/DeliveryFile' * fileUrl: * type: string * example: /order/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: Order not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /order/:id/pdf app.post(`${basePath}/order/: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, 'order' ); res.status(201).json({ attachment, fileUrl: `/order/${id}/file/${attachment.fileId}`, }); } catch (error) { const status = orderErrorStatus(error); if (status === 500) console.error('Create order PDF failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /order/{id}/file/{fileId}: * get: * tags: [Order] * summary: Get order attachment file content * description: | * Returns the full binary content of an attached file (e.g. order PDF) * for the specified order. 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: Order 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: Order or file not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /order/:id/file/:fileId app.get(`${basePath}/order/: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 = orderErrorStatus(error); if (status === 500) console.error('Get order file failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /order/{id}/file/{fileId}: * delete: * tags: [Order] * summary: Delete order file attachment * description: | * Removes a file attachment from the order and deletes the underlying file. * security: * - cookieAuth: [] * x-signals: [order] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Order 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: Order or attachment not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /order/:id/file/:fileId app.delete(`${basePath}/order/: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('order', { companyId: selected.companyId }); res.sendStatus(204); } catch (error) { const status = orderErrorStatus(error); if (status === 500) console.error('Delete order attachment failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }