import * as db from '../../db/articlesupplier.js'; import { emit } from '../sse.js'; function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } function parseSku(value) { const sku = typeof value === 'string' ? value.trim() : ''; if (!sku) return { error: 'sku is required' }; return { sku: decodeURIComponent(sku) }; } function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } return { id }; } function emitArticleSignal(req, sku) { const companyId = req.user?.selectedCompanyId; if (companyId == null) return; emit(`article-${sku}`, { companyId }); } function articleSupplierErrorStatus(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 === 'Article not found') return 404; if (error.message === 'Supplier not found') return 404; if (error.message === 'Invalid supplierId') return 400; if (error.message === 'Supplier assignment not found') return 404; if (error.message === 'Supplier already assigned') return 409; if (error.message === 'Supplier required' || error.message === 'entityId is required') return 400; if (error.message === 'Invalid pu' || error.message === 'pu must be greater than 0') return 400; if (error.message === 'Invalid stock') return 400; if (error.message === 'Invalid sort') return 400; if (error.message === 'Invalid supplier order') return 400; if (error.message === 'Invalid price') return 400; if (error.message === 'sku is required') return 400; if (error.message === 'id is required') return 400; if (error.message === 'ids is required') return 400; return 500; } /** * Registers article supplier routes under `/article/:sku/supplier`. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /article/{sku}/supplier: * get: * tags: [Article] * summary: List article suppliers * description: Fetches all suppliers linked to the specified article SKU. * security: * - cookieAuth: [] * x-markdown: article/supplier/list.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * responses: * 200: * description: List of suppliers linked to the article * content: * application/json: * schema: * type: object * properties: * suppliers: * type: array * items: * $ref: '#/components/schemas/ArticleSupplier' * 400: * description: No company selected or invalid sku * 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: Article or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * post: * tags: [Article] * summary: Assign supplier to article * description: | * Assigns a supplier to an article with optional purchase price, stock, and supplier SKU. * security: * - cookieAuth: [] * x-signals: [article-] * x-markdown: article/supplier/create.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [entityId] * properties: * entityId: * type: integer * description: Supplier entity ID (in Lieferanten group) * sku: * type: string * nullable: true * description: Supplier's item number * pu: * type: number * default: 1 * description: Packaging unit (VPE) * stock: * type: number * nullable: true * description: Supplier stock * sort: * type: integer * nullable: true * description: Display sort order (defaults to next sort) * netPrice: * type: number * nullable: true * description: Net purchase price * taxId: * type: integer * nullable: true * description: Tax ID for purchase price * taxRate: * type: number * nullable: true * description: Tax rate in percent * example: * entityId: 88 * sku: MET-44910 * pu: 1 * stock: 140 * sort: 0 * netPrice: 4.20 * taxId: 1 * responses: * 201: * description: Created supplier assignment * content: * application/json: * schema: * type: object * properties: * supplier: * $ref: '#/components/schemas/ArticleSupplier' * 400: * description: entityId required, invalid pu, invalid stock, invalid price, 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: Article, supplier, or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Supplier already assigned * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ app.get(`${basePath}/article/:sku/supplier`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const skuParsed = parseSku(req.params.sku); if (skuParsed.error) { return res.status(400).json({ error: skuParsed.error }); } try { const suppliers = await db.listByArticleSku(req.user.id, skuParsed.sku); res.status(200).json({ suppliers }); } catch (error) { const status = articleSupplierErrorStatus(error); if (status === 500) console.error('List article suppliers failed:', error); res.status(status).json({ error: error.message }); } }); app.post(`${basePath}/article/:sku/supplier`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const skuParsed = parseSku(req.params.sku); if (skuParsed.error) { return res.status(400).json({ error: skuParsed.error }); } const { entityId, sku: supplierSku, pu, stock, sort, netPrice, taxId, taxRate } = req.body ?? {}; if (entityId == null) { return res.status(400).json({ error: 'entityId is required' }); } if (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0) { return res.status(400).json({ error: 'entityId must be an integer' }); } if (pu !== undefined && pu !== null && (typeof pu !== 'number' || !Number.isFinite(pu) || pu <= 0)) { return res.status(400).json({ error: 'pu must be greater than 0' }); } if (stock !== undefined && stock !== null && (typeof stock !== 'number' || !Number.isFinite(stock))) { return res.status(400).json({ error: 'Invalid stock' }); } if (sort !== undefined && sort !== null && (typeof sort !== 'number' || !Number.isInteger(sort) || sort < 0)) { return res.status(400).json({ error: 'Invalid sort' }); } if (netPrice !== undefined && netPrice !== null && (typeof netPrice !== 'number' || !Number.isFinite(netPrice) || netPrice < 0)) { return res.status(400).json({ error: 'Invalid price' }); } if (taxId !== undefined && taxId !== null && (typeof taxId !== 'number' || !Number.isInteger(taxId) || taxId <= 0)) { return res.status(400).json({ error: 'Invalid taxId' }); } if (taxRate !== undefined && taxRate !== null && (typeof taxRate !== 'number' || !Number.isFinite(taxRate) || taxRate < 0)) { return res.status(400).json({ error: 'Invalid taxRate' }); } try { const supplier = await db.create(req.user.id, skuParsed.sku, { entityId, sku: supplierSku, pu, stock, sort, netPrice, taxId, taxRate, }); emitArticleSignal(req, skuParsed.sku); res.status(201).json({ supplier }); } catch (error) { const status = articleSupplierErrorStatus(error); if (status === 500) console.error('Create article supplier failed:', error); res.status(status).json({ error: error.message }); } }); /** * @openapi * /article/{sku}/supplier/{id}: * patch: * tags: [Article] * summary: Update article supplier assignment * description: | * Updates an existing article-supplier association (supplier SKU, packaging unit, stock, purchase price). * security: * - cookieAuth: [] * x-signals: [article-] * x-markdown: article/supplier/update.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * - in: path * name: id * required: true * schema: * type: integer * description: articlesupplier assignment ID * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sku: * type: string * nullable: true * pu: * type: number * stock: * type: number * nullable: true * sort: * type: integer * description: Display sort order * netPrice: * type: number * nullable: true * taxId: * type: integer * nullable: true * taxRate: * type: number * nullable: true * example: * sku: MET-44910-B * pu: 6 * stock: 95 * netPrice: 22.50 * responses: * 200: * description: Updated supplier assignment * content: * application/json: * schema: * type: object * properties: * supplier: * $ref: '#/components/schemas/ArticleSupplier' * 400: * description: Invalid pu, invalid stock, invalid price, 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: Supplier assignment, article, or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * delete: * tags: [Article] * summary: Remove supplier assignment from article * description: | * Removes a supplier assignment from the article and cleans up its associated purchase price. * security: * - cookieAuth: [] * x-signals: [article-] * x-markdown: article/supplier/delete.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * - in: path * name: id * required: true * schema: * type: integer * description: articlesupplier assignment ID * responses: * 204: * description: Deleted * 400: * description: Invalid id, invalid sku, 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: Supplier assignment or article not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ app.patch(`${basePath}/article/:sku/supplier/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const skuParsed = parseSku(req.params.sku); if (skuParsed.error) { return res.status(400).json({ error: skuParsed.error }); } const idParsed = parsePositiveInt(req.params.id, 'id'); if (idParsed.error) { return res.status(400).json({ error: idParsed.error }); } const body = req.body ?? {}; const patch = {}; if (body.sku !== undefined) patch.sku = body.sku; if (body.pu !== undefined) { if (body.pu === null || !Number.isFinite(body.pu) || body.pu <= 0) { return res.status(400).json({ error: 'pu must be greater than 0' }); } patch.pu = body.pu; } if (body.stock !== undefined) { if (body.stock !== null && !Number.isFinite(body.stock)) { return res.status(400).json({ error: 'Invalid stock' }); } patch.stock = body.stock; } if (body.sort !== undefined) { if (body.sort === null || !Number.isInteger(body.sort) || body.sort < 0) { return res.status(400).json({ error: 'Invalid sort' }); } patch.sort = body.sort; } if (body.netPrice !== undefined) { if (body.netPrice !== null && !Number.isFinite(body.netPrice)) { return res.status(400).json({ error: 'Invalid price' }); } patch.netPrice = body.netPrice; } if (body.taxId !== undefined) { if (body.taxId !== null && (typeof body.taxId !== 'number' || !Number.isInteger(body.taxId) || body.taxId <= 0)) { return res.status(400).json({ error: 'Invalid taxId' }); } patch.taxId = body.taxId; } if (body.taxRate !== undefined) { if (body.taxRate !== null && (typeof body.taxRate !== 'number' || !Number.isFinite(body.taxRate) || body.taxRate < 0)) { return res.status(400).json({ error: 'Invalid taxRate' }); } patch.taxRate = body.taxRate; } try { const supplier = await db.update(req.user.id, skuParsed.sku, idParsed.id, patch); emitArticleSignal(req, skuParsed.sku); res.status(200).json({ supplier }); } catch (error) { const status = articleSupplierErrorStatus(error); if (status === 500) console.error('Update article supplier failed:', error); res.status(status).json({ error: error.message }); } }); app.delete(`${basePath}/article/:sku/supplier/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const skuParsed = parseSku(req.params.sku); if (skuParsed.error) { return res.status(400).json({ error: skuParsed.error }); } const idParsed = parsePositiveInt(req.params.id, 'id'); if (idParsed.error) { return res.status(400).json({ error: idParsed.error }); } try { await db.remove(req.user.id, skuParsed.sku, idParsed.id); emitArticleSignal(req, skuParsed.sku); res.sendStatus(204); } catch (error) { const status = articleSupplierErrorStatus(error); if (status === 500) console.error('Delete article supplier failed:', error); res.status(status).json({ error: error.message }); } }); /** * @openapi * /article/{sku}/supplier/order: * put: * tags: [Article] * summary: Reorder article suppliers * description: | * Sets the display sort order of supplier assignments for an article. * Expects an array of all assigned `articlesupplier` IDs in the desired order. * security: * - cookieAuth: [] * x-signals: [article-] * x-markdown: article/supplier/order.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [ids] * properties: * ids: * type: array * items: * type: integer * description: Full list of articlesupplier ids in their new order * example: * ids: [12, 10, 15] * responses: * 200: * description: New order applied * content: * application/json: * schema: * type: object * properties: * ids: * type: array * items: * type: integer * 400: * description: Invalid supplier order, ids is required, 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: Article or company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ app.put(`${basePath}/article/:sku/supplier/order`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const skuParsed = parseSku(req.params.sku); if (skuParsed.error) { return res.status(400).json({ error: skuParsed.error }); } const rawIds = req.body?.ids; if (!Array.isArray(rawIds)) { return res.status(400).json({ error: 'ids is required' }); } const ids = []; for (const value of rawIds) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: 'Invalid supplier order' }); } ids.push(id); } try { const result = await db.order(req.user.id, skuParsed.sku, { ids }); emitArticleSignal(req, skuParsed.sku); res.status(200).json(result); } catch (error) { const status = articleSupplierErrorStatus(error); if (status === 500) console.error('Order article suppliers failed:', error); res.status(status).json({ error: error.message }); } }); }