From 69388c789ff3586222085378284a15bf6aae2028 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 26 Oct 2026 19:22:01 +0100 Subject: [PATCH] u --- docs/api/README.md | 14 + docs/api/routes-all.md | 50 ++- docs/api/routes.md | 50 ++- docs/api/routes/retoure.get.md | 36 ++ docs/api/routes/retoure.post.md | 33 ++ docs/api/routes/retoure/_id.delete.md | 21 ++ docs/api/routes/retoure/_id.get.md | 25 ++ docs/api/routes/retoure/_id.put.md | 25 ++ docs/api/routes/retoure/_id/shipment.md | 19 + docs/api/routes/retoure/rebuild-summaries.md | 13 + docs/api/routes/return.get.md | 23 ++ docs/api/routes/return.post.md | 19 + docs/api/routes/return/_id.delete.md | 21 ++ docs/api/routes/return/_id.get.md | 19 + docs/api/routes/return/_id.put.md | 19 + docs/api/routes/return/_id/book.md | 19 + docs/api/routes/return/rebuild-summaries.md | 13 + src/api/routes/retoure.js | 359 +++++++++++++++++++ src/api/routes/return.js | 319 ++++++++++++++++ src/db/retoure.js | 148 ++++++++ src/db/return.js | 36 ++ src/db/store.js | 2 + src/services/companyImport/orderImporter.js | 52 +-- 23 files changed, 1307 insertions(+), 28 deletions(-) create mode 100644 docs/api/routes/retoure.get.md create mode 100644 docs/api/routes/retoure.post.md create mode 100644 docs/api/routes/retoure/_id.delete.md create mode 100644 docs/api/routes/retoure/_id.get.md create mode 100644 docs/api/routes/retoure/_id.put.md create mode 100644 docs/api/routes/retoure/_id/shipment.md create mode 100644 docs/api/routes/retoure/rebuild-summaries.md create mode 100644 docs/api/routes/return.get.md create mode 100644 docs/api/routes/return.post.md create mode 100644 docs/api/routes/return/_id.delete.md create mode 100644 docs/api/routes/return/_id.get.md create mode 100644 docs/api/routes/return/_id.put.md create mode 100644 docs/api/routes/return/_id/book.md create mode 100644 docs/api/routes/return/rebuild-summaries.md create mode 100644 src/api/routes/retoure.js create mode 100644 src/api/routes/return.js create mode 100644 src/db/retoure.js create mode 100644 src/db/return.js diff --git a/docs/api/README.md b/docs/api/README.md index 5535159..c362ef6 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -241,6 +241,20 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/quote/_id/file/_fileId.get.md`](routes/quote/_id/file/_fileId.get.md) | `GET /quote/{id}/file/{fileId}` | | [`routes/quote/order.md`](routes/quote/order.md) | `POST /quote/{id}/order` | | [`routes/quote/_id/pdf.md`](routes/quote/_id/pdf.md) | `POST /quote/{id}/pdf` | +| [`routes/retoure.get.md`](routes/retoure.get.md) | `GET /retoure` | +| [`routes/retoure.post.md`](routes/retoure.post.md) | `POST /retoure` | +| [`routes/retoure/rebuild-summaries.md`](routes/retoure/rebuild-summaries.md) | `POST /retoure/rebuild-summaries` | +| [`routes/retoure/_id.delete.md`](routes/retoure/_id.delete.md) | `DELETE /retoure/{id}` | +| [`routes/retoure/_id.get.md`](routes/retoure/_id.get.md) | `GET /retoure/{id}` | +| [`routes/retoure/_id.put.md`](routes/retoure/_id.put.md) | `PUT /retoure/{id}` | +| [`routes/retoure/_id/shipment.md`](routes/retoure/_id/shipment.md) | `POST /retoure/{id}/shipment` | +| [`routes/return.get.md`](routes/return.get.md) | `GET /return` | +| [`routes/return.post.md`](routes/return.post.md) | `POST /return` | +| [`routes/return/rebuild-summaries.md`](routes/return/rebuild-summaries.md) | `POST /return/rebuild-summaries` | +| [`routes/return/_id.delete.md`](routes/return/_id.delete.md) | `DELETE /return/{id}` | +| [`routes/return/_id.get.md`](routes/return/_id.get.md) | `GET /return/{id}` | +| [`routes/return/_id.put.md`](routes/return/_id.put.md) | `PUT /return/{id}` | +| [`routes/return/_id/book.md`](routes/return/_id/book.md) | `POST /return/{id}/book` | | [`routes/serper/image.md`](routes/serper/image.md) | `POST /serper/image` | | [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` | | [`routes/shipment.md`](routes/shipment.md) | `GET /shipment` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 0c7b943..8513a96 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 270 API endpoints across all modules. +Overview of all 284 API endpoints across all modules. ### Notation @@ -787,6 +787,54 @@ _none_ - Params: `id: int` - Returns: `201 { attachment: T9, fileUrl: str }` +### Section: /retoure + +#### List retoures — `GET /retoure` +- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, entityId?: int, seekMonths?: int` +- Returns: `200` +#### Create retoure — `POST /retoure` +- Params: `body: { date: str, number?: str, entityId?: int, externalNumber?: str, lines?: any[] }` +- Returns: `201` +#### Rebuild retoure summaries — `POST /retoure/rebuild-summaries` +_none_ +- Returns: `200` +#### Delete retoure — `DELETE /retoure/{id}` +- Params: `id: int` +- Returns: `204 void` +#### Get retoure by ID — `GET /retoure/{id}` +- Params: `id: int` +- Returns: `200` +#### Update retoure — `PUT /retoure/{id}` +- Params: `id: int, body: {}` +- Returns: `200` +#### Create shipment for retoure — `POST /retoure/{id}/shipment` +- Params: `id: int` +- Returns: `201` + +### Section: /return + +#### List returns — `GET /return` +- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int` +- Returns: `200` +#### Create return — `POST /return` +- Params: `body: {}` +- Returns: `201` +#### Rebuild return summaries — `POST /return/rebuild-summaries` +_none_ +- Returns: `200` +#### Delete return — `DELETE /return/{id}` +- Params: `id: int` +- Returns: `204 void` +#### Get return by ID — `GET /return/{id}` +- Params: `id: int` +- Returns: `200` +#### Update return — `PUT /return/{id}` +- Params: `id: int` +- Returns: `200` +#### Book return to ledger — `POST /return/{id}/book` +- Params: `id: int` +- Returns: `200` + ### Section: /serper #### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image` diff --git a/docs/api/routes.md b/docs/api/routes.md index 824e02e..39760fa 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 190 API endpoints across all modules. +Overview of all 204 API endpoints across all modules. ### Notation @@ -554,6 +554,54 @@ _none_ - Params: `id: int` - Returns: `201 { attachment: T7, fileUrl: str }` +### Section: /retoure + +#### List retoures — `GET /retoure` +- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, entityId?: int, seekMonths?: int` +- Returns: `200` +#### Create retoure — `POST /retoure` +- Params: `body: { date: str, number?: str, entityId?: int, externalNumber?: str, lines?: any[] }` +- Returns: `201` +#### Rebuild retoure summaries — `POST /retoure/rebuild-summaries` +_none_ +- Returns: `200` +#### Delete retoure — `DELETE /retoure/{id}` +- Params: `id: int` +- Returns: `204 void` +#### Get retoure by ID — `GET /retoure/{id}` +- Params: `id: int` +- Returns: `200` +#### Update retoure — `PUT /retoure/{id}` +- Params: `id: int, body: {}` +- Returns: `200` +#### Create shipment for retoure — `POST /retoure/{id}/shipment` +- Params: `id: int` +- Returns: `201` + +### Section: /return + +#### List returns — `GET /return` +- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int` +- Returns: `200` +#### Create return — `POST /return` +- Params: `body: {}` +- Returns: `201` +#### Rebuild return summaries — `POST /return/rebuild-summaries` +_none_ +- Returns: `200` +#### Delete return — `DELETE /return/{id}` +- Params: `id: int` +- Returns: `204 void` +#### Get return by ID — `GET /return/{id}` +- Params: `id: int` +- Returns: `200` +#### Update return — `PUT /return/{id}` +- Params: `id: int` +- Returns: `200` +#### Book return to ledger — `POST /return/{id}/book` +- Params: `id: int` +- Returns: `200` + ### Section: /shipment #### List shipments — `GET /shipment` diff --git a/docs/api/routes/retoure.get.md b/docs/api/routes/retoure.get.md new file mode 100644 index 0000000..cae5071 --- /dev/null +++ b/docs/api/routes/retoure.get.md @@ -0,0 +1,36 @@ +# `GET /retoure` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List retoures + +Lists customer return manifests (`manifest` rows with `type = 'retoure'`) +for the authenticated user's selected company. + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `offset` | no | integer | | +| `limit` | no | integer | | +| `search` | no | string | | +| `onlyUnpaid` | no | boolean | | +| `onlyUndelivered` | no | boolean | | +| `entityId` | no | integer | | +| `seekMonths` | no | integer | | + +## Response `200` + +List of retoures + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Validation error | +| 403 | Not authorized | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/retoure.post.md b/docs/api/routes/retoure.post.md new file mode 100644 index 0000000..31c03e4 --- /dev/null +++ b/docs/api/routes/retoure.post.md @@ -0,0 +1,33 @@ +# `POST /retoure` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create retoure + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `number` | no | string | | +| `date` | yes | string | | +| `entityId` | no | integer | | +| `externalNumber` | no | string | | +| `lines` | no | any[] | | + +```json +{ + "number": "…", + "date": "…", + "entityId": 1, + "externalNumber": "…", + "lines": [] +} +``` + +## Response `201` + +Retoure created + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/retoure/_id.delete.md b/docs/api/routes/retoure/_id.delete.md new file mode 100644 index 0000000..9883caa --- /dev/null +++ b/docs/api/routes/retoure/_id.delete.md @@ -0,0 +1,21 @@ +# `DELETE /retoure/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete retoure + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `204` + +Retoure deleted + +_No content_ + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/retoure/_id.get.md b/docs/api/routes/retoure/_id.get.md new file mode 100644 index 0000000..21a27f3 --- /dev/null +++ b/docs/api/routes/retoure/_id.get.md @@ -0,0 +1,25 @@ +# `GET /retoure/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get retoure by ID + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Retoure details + +## Errors + +| Status | Description | +|--------|-------------| +| 404 | Retoure not found | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/retoure/_id.put.md b/docs/api/routes/retoure/_id.put.md new file mode 100644 index 0000000..3a1025e --- /dev/null +++ b/docs/api/routes/retoure/_id.put.md @@ -0,0 +1,25 @@ +# `PUT /retoure/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Update retoure + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Body (JSON) + +```json +{} +``` + +## Response `200` + +Retoure updated + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/retoure/_id/shipment.md b/docs/api/routes/retoure/_id/shipment.md new file mode 100644 index 0000000..8b5e89e --- /dev/null +++ b/docs/api/routes/retoure/_id/shipment.md @@ -0,0 +1,19 @@ +# `POST /retoure/{id}/shipment` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create shipment for retoure + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `201` + +Shipment created + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/retoure/rebuild-summaries.md b/docs/api/routes/retoure/rebuild-summaries.md new file mode 100644 index 0000000..ad72c2d --- /dev/null +++ b/docs/api/routes/retoure/rebuild-summaries.md @@ -0,0 +1,13 @@ +# `POST /retoure/rebuild-summaries` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Rebuild retoure summaries + +## Response `200` + +Summaries rebuilt + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/return.get.md b/docs/api/routes/return.get.md new file mode 100644 index 0000000..ec8c8d5 --- /dev/null +++ b/docs/api/routes/return.get.md @@ -0,0 +1,23 @@ +# `GET /return` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List returns + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `offset` | no | integer | | +| `limit` | no | integer | | +| `search` | no | string | | +| `onlyUnbooked` | no | boolean | | +| `supplierId` | no | integer | | + +## Response `200` + +List of returns + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/return.post.md b/docs/api/routes/return.post.md new file mode 100644 index 0000000..aaa848b --- /dev/null +++ b/docs/api/routes/return.post.md @@ -0,0 +1,19 @@ +# `POST /return` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create return + +## Body (JSON) + +```json +{} +``` + +## Response `201` + +Return created + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/return/_id.delete.md b/docs/api/routes/return/_id.delete.md new file mode 100644 index 0000000..d6f9309 --- /dev/null +++ b/docs/api/routes/return/_id.delete.md @@ -0,0 +1,21 @@ +# `DELETE /return/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete return + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `204` + +Return deleted + +_No content_ + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/return/_id.get.md b/docs/api/routes/return/_id.get.md new file mode 100644 index 0000000..52461f9 --- /dev/null +++ b/docs/api/routes/return/_id.get.md @@ -0,0 +1,19 @@ +# `GET /return/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get return by ID + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Return details + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/return/_id.put.md b/docs/api/routes/return/_id.put.md new file mode 100644 index 0000000..f220aa6 --- /dev/null +++ b/docs/api/routes/return/_id.put.md @@ -0,0 +1,19 @@ +# `PUT /return/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Update return + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Return updated + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/return/_id/book.md b/docs/api/routes/return/_id/book.md new file mode 100644 index 0000000..8bf7306 --- /dev/null +++ b/docs/api/routes/return/_id/book.md @@ -0,0 +1,19 @@ +# `POST /return/{id}/book` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Book return to ledger + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Return booked + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/return/rebuild-summaries.md b/docs/api/routes/return/rebuild-summaries.md new file mode 100644 index 0000000..e72a5ab --- /dev/null +++ b/docs/api/routes/return/rebuild-summaries.md @@ -0,0 +1,13 @@ +# `POST /return/rebuild-summaries` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Rebuild return summaries + +## Response `200` + +Summaries rebuilt + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/routes/retoure.js b/src/api/routes/retoure.js new file mode 100644 index 0000000..a567f7f --- /dev/null +++ b/src/api/routes/retoure.js @@ -0,0 +1,359 @@ +import * as db from '../../db/retoure.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 retoureErrorStatus(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 === 'Retoure not found') return 404; + if (error.message === 'Manifest 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 === 'Retoure number already exists') return 409; + if (error.message === 'Invalid entityId') return 400; + if (error.message === 'Invalid retoure') 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?.includes('parent article') || error.message?.includes('A parent article')) return 400; + return 500; +} + +// #endregion + +/** + * Registers retoure (customer return) routes for the authenticated user's selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /retoure: + * get: + * tags: [Retoure] + * summary: List retoures + * description: | + * Lists customer return manifests (`manifest` rows with `type = 'retoure'`) + * for the authenticated user's selected company. + * security: + * - cookieAuth: [] + * parameters: + * - in: query + * name: offset + * schema: + * type: integer + * default: 0 + * - in: query + * name: limit + * schema: + * type: integer + * default: 50 + * - in: query + * name: search + * schema: + * type: string + * - in: query + * name: onlyUnpaid + * schema: + * type: boolean + * - in: query + * name: onlyUndelivered + * schema: + * type: boolean + * - in: query + * name: entityId + * schema: + * type: integer + * - in: query + * name: seekMonths + * schema: + * type: integer + * responses: + * 200: + * description: List of retoures + * 400: + * description: Validation error + * 403: + * description: Not authorized + */ + app.get(`${basePath}/retoure`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const opts = {}; + if (req.query.offset !== undefined) opts.offset = req.query.offset; + if (req.query.limit !== undefined) opts.limit = req.query.limit; + if (typeof req.query.search === 'string') opts.search = req.query.search; + if (req.query.onlyUnpaid !== undefined) opts.onlyUnpaid = req.query.onlyUnpaid === 'true' || req.query.onlyUnpaid === '1'; + if (req.query.onlyUndelivered !== undefined) opts.onlyUndelivered = req.query.onlyUndelivered === 'true' || req.query.onlyUndelivered === '1'; + if (req.query.entityId !== undefined) opts.entityId = req.query.entityId; + if (req.query.seekMonths !== undefined) opts.seekMonths = Number(req.query.seekMonths); + + try { + const result = await db.list(req.user.id, opts); + res.status(200).json(result); + } catch (error) { + const status = retoureErrorStatus(error); + if (status === 500) console.error('List retoures failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /retoure/{id}: + * get: + * tags: [Retoure] + * summary: Get retoure by ID + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Retoure details + * 404: + * description: Retoure not found + */ + app.get(`${basePath}/retoure/:id`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + const retoure = await db.getById(req.user.id, id); + res.status(200).json({ retoure }); + } catch (error) { + const status = retoureErrorStatus(error); + if (status === 500) console.error('Get retoure failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /retoure: + * post: + * tags: [Retoure] + * summary: Create retoure + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [date] + * properties: + * number: + * type: string + * date: + * type: string + * entityId: + * type: integer + * externalNumber: + * type: string + * lines: + * type: array + * responses: + * 201: + * description: Retoure created + */ + app.post(`${basePath}/retoure`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + try { + const retoure = await db.create(req.user.id, req.body); + emit('retoure', { companyId: companyCheck.companyId }); + res.status(201).json({ retoure }); + } catch (error) { + const status = retoureErrorStatus(error); + if (status === 500) console.error('Create retoure failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /retoure/{id}: + * put: + * tags: [Retoure] + * summary: Update retoure + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * responses: + * 200: + * description: Retoure updated + */ + app.put(`${basePath}/retoure/:id`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + const retoure = await db.update(req.user.id, id, req.body); + emit('retoure', { companyId: companyCheck.companyId }); + res.status(200).json({ retoure }); + } catch (error) { + const status = retoureErrorStatus(error); + if (status === 500) console.error('Update retoure failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /retoure/{id}: + * delete: + * tags: [Retoure] + * summary: Delete retoure + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Retoure deleted + */ + app.delete(`${basePath}/retoure/:id`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + await db.remove(req.user.id, id); + emit('retoure', { companyId: companyCheck.companyId }); + res.sendStatus(204); + } catch (error) { + const status = retoureErrorStatus(error); + if (status === 500) console.error('Delete retoure failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /retoure/{id}/shipment: + * post: + * tags: [Retoure] + * summary: Create shipment for retoure + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 201: + * description: Shipment created + */ + app.post(`${basePath}/retoure/:id/shipment`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + const shipment = await db.createShipment(req.user.id, id, req.body); + emit('retoure', { companyId: companyCheck.companyId }); + emit('shipment', { companyId: companyCheck.companyId }); + res.status(201).json({ shipment }); + } catch (error) { + const status = retoureErrorStatus(error); + if (status === 500) console.error('Create shipment for retoure failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /retoure/rebuild-summaries: + * post: + * tags: [Retoure] + * summary: Rebuild retoure summaries + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Summaries rebuilt + */ + app.post(`${basePath}/retoure/rebuild-summaries`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + try { + const result = await db.rebuildSummaries(req.user.id); + res.status(200).json(result); + } catch (error) { + const status = retoureErrorStatus(error); + if (status === 500) console.error('Rebuild retoure summaries failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} diff --git a/src/api/routes/return.js b/src/api/routes/return.js new file mode 100644 index 0000000..35d686d --- /dev/null +++ b/src/api/routes/return.js @@ -0,0 +1,319 @@ +import * as db from '../../db/return.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 returnErrorStatus(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 === 'Return not found') return 404; + if (error.message === 'Supplier not found') return 404; + if (error.message === 'No supplier selected') return 400; + if (error.message === 'Return number already exists') return 409; + if (error.message === 'Invalid supplierId') return 400; + if (error.message === 'Invalid return') 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 === 'Return lines already booked on ledger') return 409; + if (error.message === 'Return in use in ledger') return 409; + return 500; +} + +// #endregion + +/** + * Registers return (supplier return) routes for the authenticated user's selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /return: + * get: + * tags: [Return] + * summary: List returns + * security: + * - cookieAuth: [] + * parameters: + * - in: query + * name: offset + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * - in: query + * name: search + * schema: + * type: string + * - in: query + * name: onlyUnbooked + * schema: + * type: boolean + * - in: query + * name: supplierId + * schema: + * type: integer + * responses: + * 200: + * description: List of returns + */ + app.get(`${basePath}/return`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const opts = {}; + if (req.query.offset !== undefined) opts.offset = req.query.offset; + if (req.query.limit !== undefined) opts.limit = req.query.limit; + if (typeof req.query.search === 'string') opts.search = req.query.search; + if (req.query.onlyUnbooked !== undefined) opts.onlyUnbooked = req.query.onlyUnbooked === 'true' || req.query.onlyUnbooked === '1'; + if (req.query.supplierId !== undefined) opts.supplierId = req.query.supplierId; + + try { + const result = await db.list(req.user.id, opts); + res.status(200).json(result); + } catch (error) { + const status = returnErrorStatus(error); + if (status === 500) console.error('List returns failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /return/{id}: + * get: + * tags: [Return] + * summary: Get return by ID + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Return details + */ + app.get(`${basePath}/return/:id`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + const ret = await db.getById(req.user.id, id); + res.status(200).json({ return: ret }); + } catch (error) { + const status = returnErrorStatus(error); + if (status === 500) console.error('Get return failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /return: + * post: + * tags: [Return] + * summary: Create return + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [date, supplierId] + * responses: + * 201: + * description: Return created + */ + app.post(`${basePath}/return`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + try { + const ret = await db.create(req.user.id, req.body); + emit('return', { companyId: companyCheck.companyId }); + res.status(201).json({ return: ret }); + } catch (error) { + const status = returnErrorStatus(error); + if (status === 500) console.error('Create return failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /return/{id}: + * put: + * tags: [Return] + * summary: Update return + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Return updated + */ + app.put(`${basePath}/return/:id`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + const ret = await db.update(req.user.id, id, req.body); + emit('return', { companyId: companyCheck.companyId }); + res.status(200).json({ return: ret }); + } catch (error) { + const status = returnErrorStatus(error); + if (status === 500) console.error('Update return failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /return/{id}: + * delete: + * tags: [Return] + * summary: Delete return + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Return deleted + */ + app.delete(`${basePath}/return/:id`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + await db.remove(req.user.id, id); + emit('return', { companyId: companyCheck.companyId }); + res.sendStatus(204); + } catch (error) { + const status = returnErrorStatus(error); + if (status === 500) console.error('Delete return failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /return/{id}/book: + * post: + * tags: [Return] + * summary: Book return to ledger + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Return booked + */ + app.post(`${basePath}/return/:id/book`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id must be an integer' }); + } + + try { + const ret = await db.book(req.user.id, { id, warehouseId: req.body?.warehouseId }); + emit('return', { companyId: companyCheck.companyId }); + emit('stock', { companyId: companyCheck.companyId }); + res.status(200).json({ return: ret }); + } catch (error) { + const status = returnErrorStatus(error); + if (status === 500) console.error('Book return failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /return/rebuild-summaries: + * post: + * tags: [Return] + * summary: Rebuild return summaries + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Summaries rebuilt + */ + app.post(`${basePath}/return/rebuild-summaries`, async (req, res) => { + const companyCheck = requireSelectedCompany(req); + if (companyCheck.error) { + return res.status(400).json({ error: companyCheck.error }); + } + + try { + const result = await db.rebuildSummaries(req.user.id); + res.status(200).json(result); + } catch (error) { + const status = returnErrorStatus(error); + if (status === 500) console.error('Rebuild return summaries failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} diff --git a/src/db/retoure.js b/src/db/retoure.js new file mode 100644 index 0000000..58fa241 --- /dev/null +++ b/src/db/retoure.js @@ -0,0 +1,148 @@ +import { getStore } from './store.js'; +import { formatAttachments } from '../util/manifestAttachment.js'; + +function round2(num) { + return Math.round((Number(num) || 0) * 100) / 100; +} + +function calculateGrossTotal(lines = []) { + let total = 0; + for (const line of lines) { + const amount = Number(line.amount || 0); + const net = Number(line.net || 0); + const rebate = Number(line.rebate || 0); + const taxrate = Number(line.taxrate || 0); + const netAfterRebate = net * (1 - rebate / 100); + const gross = netAfterRebate * (1 + taxrate / 100); + total += amount * gross; + } + return round2(total); +} + +function calculatePaidTotal(payments = []) { + let total = 0; + for (const payment of payments) { + total += Number(payment.amount || 0); + } + return round2(total); +} + +function calculateOrderedQuantity(lines = []) { + let qty = 0; + for (const line of lines) { + if (line.shippable === false) continue; + qty += Number(line.amount || 0); + } + return Math.round(qty * 1000) / 1000; +} + +function calculateShippedQuantity(shipments = []) { + let qty = 0; + for (const shipment of shipments) { + for (const line of shipment.lines || []) { + qty += Number(line.amount || 0); + } + } + return Math.round(qty * 1000) / 1000; +} + +async function enrichSingleRetoure(store, googleAuthId, retoure) { + if (!retoure) return retoure; + const [shippings, payments, shipments, rawFiles] = await Promise.all([ + store.shipping.listByManifestId(googleAuthId, retoure.id).catch(() => []), + store.payment.listByManifestId(googleAuthId, retoure.id).catch(() => []), + store.shipment.listByOrderId(googleAuthId, retoure.id).catch(() => []), + store.manifestattachment.listByManifestId(googleAuthId, retoure.id).catch(() => []), + ]); + + const files = await formatAttachments(rawFiles); + const lines = Array.isArray(retoure.lines) ? retoure.lines : []; + const paymentsList = Array.isArray(payments) ? payments : []; + const shipmentsList = Array.isArray(shipments) ? shipments : []; + + const calculatedGross = calculateGrossTotal(lines); + const calculatedPaid = calculatePaidTotal(paymentsList); + const grossTotal = retoure.grossTotal !== undefined ? retoure.grossTotal : calculatedGross; + const paidTotal = retoure.paidTotal !== undefined ? retoure.paidTotal : calculatedPaid; + + const orderedQuantity = retoure.orderedQuantity !== undefined + ? retoure.orderedQuantity + : calculateOrderedQuantity(lines); + const shippedQuantity = retoure.shippedQuantity !== undefined + ? retoure.shippedQuantity + : calculateShippedQuantity(shipmentsList); + + const isPaid = retoure.isPaid !== undefined + ? retoure.isPaid + : (Math.abs(grossTotal) < 0.01 || Math.abs(paidTotal - grossTotal) < 0.01); + + const isShipped = retoure.isShipped !== undefined + ? retoure.isShipped + : (orderedQuantity !== 0 && Math.abs(shippedQuantity - orderedQuantity) < 0.001); + + return { + ...retoure, + grossTotal, + paidTotal, + orderedQuantity, + shippedQuantity, + isPaid, + isShipped, + positions: retoure.positions ?? lines.length, + shippings: Array.isArray(shippings) ? shippings : [], + payments: paymentsList, + shipments: shipmentsList, + files, + }; +} + +export async function list(googleAuthId, opts) { + const store = await getStore(); + const result = await store.retoure.list(googleAuthId, opts); + if (Array.isArray(result)) { + return Promise.all(result.map((r) => enrichSingleRetoure(store, googleAuthId, r))); + } + if (result && Array.isArray(result.retoures)) { + const retoures = await Promise.all( + result.retoures.map((r) => enrichSingleRetoure(store, googleAuthId, r)) + ); + return { + ...result, + retoures, + }; + } + return result; +} + +export async function rebuildSummaries(googleAuthId) { + const store = await getStore(); + return store.retoure.rebuildSummaries(googleAuthId); +} + +export async function getById(googleAuthId, id) { + const store = await getStore(); + const retoure = await store.retoure.getById(googleAuthId, id); + return enrichSingleRetoure(store, googleAuthId, retoure); +} + +export async function create(googleAuthId, data) { + const store = await getStore(); + const retoure = await store.retoure.create(googleAuthId, data); + return enrichSingleRetoure(store, googleAuthId, retoure); +} + +export async function update(googleAuthId, id, data) { + const store = await getStore(); + const retoure = await store.retoure.update(googleAuthId, id, data); + return enrichSingleRetoure(store, googleAuthId, retoure); +} + +export async function remove(googleAuthId, id) { + const store = await getStore(); + return store.retoure.delete(googleAuthId, id); +} + +export async function createShipment(googleAuthId, id, data) { + const store = await getStore(); + return store.retoure.createShipment(googleAuthId, id, data); +} diff --git a/src/db/return.js b/src/db/return.js new file mode 100644 index 0000000..8cb6eb1 --- /dev/null +++ b/src/db/return.js @@ -0,0 +1,36 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId, opts) { + const store = await getStore(); + return store.return.list(googleAuthId, opts); +} + +export async function rebuildSummaries(googleAuthId) { + const store = await getStore(); + return store.return.rebuildSummaries(googleAuthId); +} + +export async function getById(googleAuthId, id) { + const store = await getStore(); + return store.return.getById(googleAuthId, id); +} + +export async function create(googleAuthId, data) { + const store = await getStore(); + return store.return.create(googleAuthId, data); +} + +export async function update(googleAuthId, id, data) { + const store = await getStore(); + return store.return.update(googleAuthId, id, data); +} + +export async function remove(googleAuthId, id) { + const store = await getStore(); + return store.return.delete(googleAuthId, id); +} + +export async function book(googleAuthId, data) { + const store = await getStore(); + return store.return.book(googleAuthId, data); +} diff --git a/src/db/store.js b/src/db/store.js index a4e5c73..275e9e9 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -30,6 +30,8 @@ const STORE_NAMESPACES = [ 'entitycontact', 'quote', 'order', + 'retoure', + 'return', 'shipment', 'numbers', 'companysetting', diff --git a/src/services/companyImport/orderImporter.js b/src/services/companyImport/orderImporter.js index 68e61f2..80790d9 100644 --- a/src/services/companyImport/orderImporter.js +++ b/src/services/companyImport/orderImporter.js @@ -290,9 +290,18 @@ export async function importOrders({ const parsedDate = dateStr ? new Date(dateStr) : new Date(); const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString(); + const orderTotal = Math.round( + lines.reduce( + (sum, l) => sum + (l.amount * l.net * (1 - (l.rebate || 0) / 100) * (1 + (l.taxrate || 0) / 100)), + 0 + ) * 100 + ) / 100; + const isRetoure = orderTotal < 0; + const domainStore = isRetoure ? store.retoure : store.order; + let createdOrder = null; try { - createdOrder = await store.order.create(googleAuthId, { + createdOrder = await domainStore.create(googleAuthId, { number: orderNumber, externalNumber: cleanValue(m.extnr ?? m.cExterneAuftragsnummer) || null, date: validDate, @@ -311,7 +320,7 @@ export async function importOrders({ if (l.sku === offendingSku) l.sku = null; } try { - createdOrder = await store.order.create(googleAuthId, { + createdOrder = await domainStore.create(googleAuthId, { number: orderNumber, externalNumber: cleanValue(m.extnr ?? m.cExterneAuftragsnummer) || null, date: validDate, @@ -322,10 +331,10 @@ export async function importOrders({ existingOrderNumbers.add(orderNumber); ordersImported++; } catch (retryErr) { - log.warn(`Failed to create order ${orderNumber} after stripping parent SKU ${offendingSku}:`, retryErr.message); + log.warn(`Failed to create ${isRetoure ? 'retoure' : 'order'} ${orderNumber} after stripping parent SKU ${offendingSku}:`, retryErr.message); } } else { - log.warn(`Failed to create order ${orderNumber}:`, err.message); + log.warn(`Failed to create ${isRetoure ? 'retoure' : 'order'} ${orderNumber}:`, err.message); } } @@ -333,29 +342,20 @@ export async function importOrders({ // Handle Payment const isPayed = String(m.payed ?? '').trim() === '1'; const paymentType = cleanValue(m.paymentType); - if (isPayed && paymentType) { - const orderTotal = Math.round( - lines.reduce( - (sum, l) => sum + (l.amount * l.net * (1 - (l.rebate || 0) / 100) * (1 + (l.taxrate || 0) / 100)), - 0 - ) * 100 - ) / 100; - - if (orderTotal > 0) { - try { - const accountId = await resolveAccountId(paymentType); - if (accountId) { - await store.payment.create(googleAuthId, { - accountId, - manifestId: createdOrder.id, - type: paymentType, - amount: orderTotal, - }); - paymentsImported++; - } - } catch (err) { - log.warn(`Failed to create payment for order ${orderNumber}:`, err.message); + if (isPayed && paymentType && orderTotal !== 0) { + try { + const accountId = await resolveAccountId(paymentType); + if (accountId) { + await store.payment.create(googleAuthId, { + accountId, + manifestId: createdOrder.id, + type: paymentType, + amount: orderTotal, + }); + paymentsImported++; } + } catch (err) { + log.warn(`Failed to create payment for ${isRetoure ? 'retoure' : 'order'} ${orderNumber}:`, err.message); } }