import * as db from '../../db/shipping.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 parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } return { id }; } function shippingErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message?.endsWith(' not found')) return 404; if ( error.message?.endsWith(' required') || error.message?.includes('not a shipment') ) { return 400; } if ( error.message?.includes('cannot be deleted') || error.message?.includes('cannot be undone') || error.message?.includes('already shipped') ) { return 409; } return 500; } function emitShippingEvents(companyId) { emit('stock', { companyId }); emit('ledger', { companyId }); emit('articles', { companyId }); emit('order', { companyId }); } // #endregion /** * Registers shipping routes (shippings belong to an order / manifest). * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /order/{id}/shipping: * get: * tags: [Shipping] * summary: List shippings of an order * description: Lists shippings of the order (manifest) for the selected company. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Order ID * responses: * 200: * description: List of shippings * content: * application/json: * schema: * type: object * properties: * shippings: * type: array * items: * $ref: '#/components/schemas/Shipping' * 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' * 404: * description: Order not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /order/:id/shipping app.get(`${basePath}/order/:id/shipping`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const shippings = await db.listByManifestId(req.user.id, parsedId.id); res.status(200).json({ shippings }); } catch (error) { const status = shippingErrorStatus(error); if (status === 500) console.error('List shippings failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /order/{id}/shipping: * post: * tags: [Shipping] * summary: Create shipping for an order * description: | * Creates a shipping for the order. The shipping number is allocated from the * `shipping` number sequence (prefix and counter from the numbers API). * If `state` is `shipped`, stock is adjusted via the ledger and article history is written. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Order ID * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [carrierId, type, state] * properties: * carrierId: * type: integer * description: Carrier ID * type: * type: string * state: * type: string * trackingCode: * type: string * description: Carrier parcel tracking code * warehouseId: * type: integer * description: Optional warehouse ID for stock deduction when state is shipped * responses: * 201: * description: Created shipping * content: * application/json: * schema: * type: object * properties: * shipping: * $ref: '#/components/schemas/Shipping' * 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: Order or warehouse not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Order already shipped * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /order/:id/shipping app.post(`${basePath}/order/:id/shipping`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const shipping = await db.create(req.user.id, { ...(req.body ?? {}), manifestId: parsedId.id, }); if (String(shipping.state || '').trim().toLowerCase() === 'shipped') { emitShippingEvents(selected.companyId); } res.status(201).json({ shipping }); } catch (error) { const status = shippingErrorStatus(error); if (status === 500) console.error('Create shipping failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /shipment/{id}/shipping: * get: * tags: [Shipping] * summary: List packages of a shipment * description: Lists all packages (shippings) belonging to the specified shipment manifest. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Shipment ID * responses: * 200: * description: List of packages * content: * application/json: * schema: * type: object * properties: * shippings: * type: array * items: * $ref: '#/components/schemas/Shipping' * 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' * 404: * description: Shipment not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /shipment/:id/shipping app.get(`${basePath}/shipment/:id/shipping`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const shippings = await db.listByManifestId(req.user.id, parsedId.id); res.status(200).json({ shippings }); } catch (error) { const status = shippingErrorStatus(error); if (status === 500) console.error('List shipment packages failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /shipment/{id}/shipping: * post: * tags: [Shipping] * summary: Create package for a shipment * description: | * Creates a physical package (shipping) for the shipment manifest. * Accepts carrier and parcel tracking code. If state is `shipped`, * deducts stock for the shipment's lines via ledger movements. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Shipment ID * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [carrierId, type, state] * properties: * carrierId: * type: integer * description: Carrier ID * type: * type: string * state: * type: string * trackingCode: * type: string * description: Carrier parcel tracking code * warehouseId: * type: integer * description: Optional warehouse ID for stock deduction when state is shipped * responses: * 201: * description: Created package * content: * application/json: * schema: * type: object * properties: * shipping: * $ref: '#/components/schemas/Shipping' * 400: * description: Validation error, manifest is not a shipment, or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Shipment or warehouse not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /shipment/:id/shipping app.post(`${basePath}/shipment/:id/shipping`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const shipping = await db.create(req.user.id, { ...(req.body ?? {}), manifestId: parsedId.id, }); if (String(shipping.state || '').trim().toLowerCase() === 'shipped') { emitShippingEvents(selected.companyId); } res.status(201).json({ shipping }); } catch (error) { const status = shippingErrorStatus(error); if (status === 500) console.error('Create shipment package failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /shipping/{id}: * patch: * tags: [Shipping] * summary: Update shipping * description: | * Updates a shipping record. If transitioned to `shipped`, stock is adjusted * via ledger and article history is written. Once shipped, it cannot be undone. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Shipping ID * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * type: * type: string * state: * type: string * trackingCode: * type: string * description: Carrier parcel tracking code * carrierId: * type: integer * description: Carrier ID * warehouseId: * type: integer * description: Warehouse ID for stock deduction when transitioning to shipped * responses: * 200: * description: Updated shipping * content: * application/json: * schema: * type: object * properties: * shipping: * $ref: '#/components/schemas/Shipping' * 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: Shipping or warehouse not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Shipped shipping cannot be undone, or order already shipped * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PATCH /shipping/:id app.patch(`${basePath}/shipping/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const shipping = await db.update(req.user.id, parsedId.id, req.body ?? {}); if (String(shipping.state || '').trim().toLowerCase() === 'shipped') { emitShippingEvents(selected.companyId); } res.status(200).json({ shipping }); } catch (error) { const status = shippingErrorStatus(error); if (status === 500) console.error('Update shipping failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /shipping/{id}: * delete: * tags: [Shipping] * summary: Delete shipping * description: | * Deletes a shipping of the selected company. Its packages are removed as well. * Shipped shippings cannot be deleted because stock movements have been ledgered. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Shipping ID * responses: * 204: * description: Shipping deleted * 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: Shipping not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 409: * description: Shipped shipping cannot be deleted * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /shipping/:id app.delete(`${basePath}/shipping/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { await db.remove(req.user.id, parsedId.id); res.status(204).end(); } catch (error) { const status = shippingErrorStatus(error); if (status === 500) console.error('Delete shipping failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }