import * as db from '../../db/stock.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 stockErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message?.includes('not found')) return 404; if ( error.message?.includes('already accepted') || error.message?.includes('already shipped') || error.message?.includes('already booked') || error.message?.includes('Stock tracking is disabled') || error.message?.startsWith('Insufficient stock') ) { return 409; } if ( error.message?.includes('required') || error.message?.includes('Invalid') || error.message?.includes('Amount must be') || error.message?.includes('Warehouse must be') || error.message?.includes('No items') || error.message?.includes('does not belong to') || error.message?.includes('cannot have stock') || error.message?.includes('parent article') || error.message?.includes('Stückliste') || error.message?.includes('has no SKU') ) { return 400; } return 500; } function emitStockEvents(companyId, body, result) { emit('stock', { companyId }); emit('ledger', { companyId }); emit('articles', { companyId }); if (body.sku) { emit(`article-${body.sku}`, { companyId }); } if (Array.isArray(body.lines)) { for (const line of body.lines) { if (line?.sku) { emit(`article-${line.sku}`, { companyId }); } } } if (Array.isArray(result?.lines)) { for (const line of result.lines) { if (line?.sku) { emit(`article-${line.sku}`, { companyId }); } } } } // #endregion /** * Registers stock adjustment routes for the authenticated user's selected company. * All operations append to the append-only ledger under an implicit "adjustment" manifest. * * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /stock/adjust: * post: * tags: [Stock] * summary: Adjust stock manually * description: | * Writes signed (+ or -) entries to the append-only ledger under an implicit manifest * of type "adjustment", allocating a number from the "adjustment" number scheme. * * **Note:** `sku` must be a string (e.g. `"25"`), not a number. For single-item adjustments, provide `sku` and `amount` at top-level. For batch adjustments, provide `lines: [{ sku, amount, ... }]`. * security: * - cookieAuth: [] * x-signals: [stock, ledger, articles, article-] * x-markdown: stock/adjust.md * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/StockAdjustmentInput' * responses: * 201: * description: Stock adjusted successfully * content: * application/json: * schema: * $ref: '#/components/schemas/StockAdjustmentResult' * 400: * description: Missing fields, invalid amount, parent article cannot have stock, 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 warehouse not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Stock tracking is disabled * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /stock/adjust app.post(`${basePath}/stock/adjust`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const result = await db.adjust(req.user.id, req.body ?? {}); emitStockEvents(selected.companyId, req.body ?? {}, result); res.status(201).json(result); } catch (error) { const status = stockErrorStatus(error); if (status === 500) console.error('Stock adjust failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /stock/accept: * post: * tags: [Stock] * summary: Accept delivery manifest item(s) * description: | * Books acceptance for a delivery manifest item on the ledger (warehouse_id = null). * If the connected article has stock tracking enabled, an adjustment entry is also * booked to adjust physical stock in the target warehouse. * If line amount is negative, books as a delivery return (outgoing). * security: * - cookieAuth: [] * x-signals: [stock, ledger, articles, delivery] * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * manifestarticleId: * type: integer * description: Manifest article ID of the delivery line * warehouseId: * type: integer * description: Optional warehouse ID for inventory intake * items: * type: array * items: * type: object * required: [manifestarticleId] * properties: * manifestarticleId: * type: integer * warehouseId: * type: integer * responses: * 200: * description: Delivery item(s) accepted successfully * 400: * description: Validation error 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: Manifest article or warehouse not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Delivery line already accepted on ledger or stock tracking disabled * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /stock/accept app.post(`${basePath}/stock/accept`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const result = await db.accept(req.user.id, req.body ?? {}); emit('stock', { companyId: selected.companyId }); emit('ledger', { companyId: selected.companyId }); emit('articles', { companyId: selected.companyId }); emit('delivery', { companyId: selected.companyId }); res.status(200).json(result); } catch (error) { const status = stockErrorStatus(error); if (status === 500) console.error('Stock accept failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /stock/ship: * post: * tags: [Stock] * summary: Ship shipment manifest item(s) * description: | * Books shipment for a shipment manifest item on the ledger (warehouse_id = null). * If the connected article has stock tracking enabled, an adjustment entry is also * booked to deduct physical stock from the target warehouse. * security: * - cookieAuth: [] * x-signals: [stock, ledger, articles, shipping] * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * manifestarticleId: * type: integer * description: Manifest article ID of the shipment line * warehouseId: * type: integer * description: Optional warehouse ID for inventory deduction * items: * type: array * items: * type: object * required: [manifestarticleId] * properties: * manifestarticleId: * type: integer * warehouseId: * type: integer * responses: * 200: * description: Shipment item(s) shipped successfully * 400: * description: Validation error 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: Manifest article or warehouse not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Shipment line already shipped on ledger or stock tracking disabled * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /stock/ship app.post(`${basePath}/stock/ship`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const result = await db.ship(req.user.id, req.body ?? {}); emit('stock', { companyId: selected.companyId }); emit('ledger', { companyId: selected.companyId }); emit('articles', { companyId: selected.companyId }); emit('shipping', { companyId: selected.companyId }); res.status(200).json(result); } catch (error) { const status = stockErrorStatus(error); if (status === 500) console.error('Stock ship failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }