diff --git a/docs/api/README.md b/docs/api/README.md index 561471f..6c4a328 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -90,6 +90,11 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/events.md`](routes/events.md) | `GET /events` | | [`routes/health.md`](routes/health.md) | `GET /health` | | [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | +| [`routes/order/list.md`](routes/order/list.md) | `GET /order` | +| [`routes/order/create.md`](routes/order/create.md) | `POST /order` | +| [`routes/order/delete.md`](routes/order/delete.md) | `DELETE /order/{id}` | +| [`routes/order/get.md`](routes/order/get.md) | `GET /order/{id}` | +| [`routes/order/update.md`](routes/order/update.md) | `PATCH /order/{id}` | | [`routes/posterminal/list.md`](routes/posterminal/list.md) | `GET /posterminal` | | [`routes/posterminal/upsert.md`](routes/posterminal/upsert.md) | `PUT /posterminal` | | [`routes/posterminal/delete.md`](routes/posterminal/delete.md) | `DELETE /posterminal/{id}` | @@ -97,6 +102,11 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/posterminal/setOnAll.md`](routes/posterminal/setOnAll.md) | `PUT /posterminal/{id}/all` | | [`routes/posterminal/setOff.md`](routes/posterminal/setOff.md) | `DELETE /posterminal/{id}/category/{categoryId}` | | [`routes/posterminal/setOn.md`](routes/posterminal/setOn.md) | `PUT /posterminal/{id}/category/{categoryId}` | +| [`routes/quote/list.md`](routes/quote/list.md) | `GET /quote` | +| [`routes/quote/create.md`](routes/quote/create.md) | `POST /quote` | +| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` | +| [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` | +| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` | | [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` | | [`routes/supplier/create.md`](routes/supplier/create.md) | `POST /supplier` | | [`routes/supplier/delete.md`](routes/supplier/delete.md) | `DELETE /supplier/{id}` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 9d123a4..d407e55 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -52,6 +52,14 @@ "name": "Delivery", "description": "Company-scoped goods receipts (Lieferungen)" }, + { + "name": "Quote", + "description": "Company-scoped quotes (Angebote)" + }, + { + "name": "Order", + "description": "Company-scoped sales orders (Aufträge)" + }, { "name": "Customergroup", "description": "Company-scoped customer groups" @@ -671,6 +679,464 @@ } } }, + "/quote": { + "get": { + "tags": [ + "Quote" + ], + "summary": "List quotes", + "description": "Lists quotes (`manifest` rows with `type = 'quote'`)\nfor the authenticated user's selected company.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "quote/list.md", + "responses": { + "200": { + "description": "Quote list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "quotes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuoteSummary" + } + } + } + } + } + } + }, + "400": { + "description": "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": [ + "Quote" + ], + "summary": "Create quote", + "description": "Creates a quote with header and lines in one transaction.\n`date` is the document date (valid-until).\n`entityId` must be a company customer (not Lieferanten/POS), or null.\nEach line requires `sku`, `amount`, `net`, and `tax` (rate); `rebate` defaults to `0`.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "quote/create.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "number", + "date", + "lines" + ], + "properties": { + "number": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Document date / valid-until" + }, + "entityId": { + "type": "integer", + "nullable": true, + "description": "Customer entity (manifest.entity_id)" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManifestLineInput" + } + } + }, + "example": { + "number": "Q-2026-001", + "date": "2026-08-31", + "entityId": 1, + "lines": [ + { + "sku": "1", + "amount": 10, + "net": 99.5, + "tax": 19 + } + ] + } + } + } + } + }, + "responses": { + "201": { + "description": "Created quote", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "quote": { + "$ref": "#/components/schemas/Quote" + } + } + } + } + } + }, + "400": { + "description": "Invalid quote, 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" + } + } + } + } + } + } + }, + "/quote/{id}": { + "get": { + "tags": [ + "Quote" + ], + "summary": "Get quote", + "description": "Returns a quote with lines for the selected company.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "quote/get.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Quote", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "quote": { + "$ref": "#/components/schemas/Quote" + } + } + } + } + } + }, + "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": "Quote or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Quote" + ], + "summary": "Update quote", + "description": "Patches header fields that are present. If `lines` is present, replaces\nall lines; if omitted, leaves lines unchanged. Returns the full quote.\n`date` is the document date (valid-until).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "quote/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 / valid-until" + }, + "entityId": { + "type": "integer", + "nullable": true + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManifestLineInput" + } + } + }, + "example": { + "number": "Q-2026-001b", + "lines": [] + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated quote", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "quote": { + "$ref": "#/components/schemas/Quote" + } + } + } + } + } + }, + "400": { + "description": "Invalid quote, 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": "Quote, entity, article, or company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Quote" + ], + "summary": "Delete quote", + "description": "Deletes the quote (`manifestarticle` rows cascade).", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "quote/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": "Quote not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/posterminal": { "get": { "tags": [ @@ -1463,6 +1929,464 @@ } } }, + "/order": { + "get": { + "tags": [ + "Order" + ], + "summary": "List orders", + "description": "Lists sales orders (`manifest` rows with `type = 'order'`)\nfor the authenticated user's selected company.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "order/list.md", + "responses": { + "200": { + "description": "Order list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderSummary" + } + } + } + } + } + } + }, + "400": { + "description": "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.\n`date` is the document date (expected delivery).\n`entityId` must be a company customer (not Lieferanten/POS), or null.\nEach line requires `sku`, `amount`, `net`, and `tax` (rate); `rebate` defaults to `0`.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "order/create.md", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "number", + "date", + "lines" + ], + "properties": { + "number": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Document date / expected delivery" + }, + "entityId": { + "type": "integer", + "nullable": true, + "description": "Customer entity (manifest.entity_id)" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManifestLineInput" + } + } + }, + "example": { + "number": "O-2026-001", + "date": "2026-09-15", + "entityId": 1, + "lines": [ + { + "sku": "1", + "amount": 10, + "net": 99.5, + "tax": 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" + } + } + } + } + } + } + }, + "/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\nall lines; if omitted, leaves lines unchanged. Returns the full order.\n`date` is the document date (expected delivery).\n", + "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 + }, + "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" + } + } + } + } + } + } + }, "/health": { "get": { "tags": [ @@ -7254,6 +8178,192 @@ } ] }, + "ManifestLine": { + "type": "object", + "required": [ + "id", + "articleId", + "sku", + "amount", + "net", + "rebate", + "tax", + "comment" + ], + "description": "Line shape for quote and order (no transactedAmount)", + "properties": { + "id": { + "type": "integer" + }, + "articleId": { + "type": "integer", + "nullable": true + }, + "sku": { + "type": "string", + "nullable": true + }, + "amount": { + "type": "number" + }, + "net": { + "type": "number" + }, + "rebate": { + "type": "number" + }, + "tax": { + "type": "number", + "description": "tax.rate" + }, + "comment": { + "type": "string", + "nullable": true + } + } + }, + "ManifestLineInput": { + "type": "object", + "required": [ + "sku", + "amount", + "net", + "tax" + ], + "properties": { + "sku": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "net": { + "type": "number" + }, + "tax": { + "type": "number", + "description": "Tax rate (resolved via tax.ensure)" + }, + "rebate": { + "type": "number", + "description": "Defaults to 0" + }, + "comment": { + "type": "string", + "nullable": true + } + } + }, + "QuoteSummary": { + "type": "object", + "required": [ + "id", + "number", + "type", + "entityId", + "date" + ], + "properties": { + "id": { + "type": "integer" + }, + "number": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "quote" + ] + }, + "entityId": { + "type": "integer", + "nullable": true, + "description": "Customer (manifest.entity_id)" + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Document date / valid-until" + } + } + }, + "Quote": { + "allOf": [ + { + "$ref": "#/components/schemas/QuoteSummary" + }, + { + "type": "object", + "required": [ + "lines" + ], + "properties": { + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManifestLine" + } + } + } + } + ] + }, + "OrderSummary": { + "type": "object", + "required": [ + "id", + "number", + "type", + "entityId", + "date" + ], + "properties": { + "id": { + "type": "integer" + }, + "number": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "order" + ] + }, + "entityId": { + "type": "integer", + "nullable": true, + "description": "Customer (manifest.entity_id)" + }, + "date": { + "type": "string", + "format": "date-time", + "description": "Document date / expected delivery" + } + } + }, + "Order": { + "allOf": [ + { + "$ref": "#/components/schemas/OrderSummary" + }, + { + "type": "object", + "required": [ + "lines" + ], + "properties": { + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManifestLine" + } + } + } + } + ] + }, "ArticleImage": { "type": "object", "required": [ diff --git a/docs/api/routes/order/create.md b/docs/api/routes/order/create.md new file mode 100644 index 0000000..df92870 --- /dev/null +++ b/docs/api/routes/order/create.md @@ -0,0 +1,70 @@ +# `POST /order` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create order + +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/POS), or null. +Each line requires `sku`, `amount`, `net`, and `tax` (rate); `rebate` defaults to `0`. + + +## Body (JSON) + +```json +{ + "number": "O-2026-001", + "date": "2026-09-15", + "entityId": 1, + "lines": [ + { + "sku": "1", + "amount": 10, + "net": 99.5, + "tax": 19 + } + ] +} +``` + +## Response `201` + +Created order + +```json +{ + "order": { + "id": 1, + "number": "…", + "type": "order", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z", + "lines": [ + { + "id": 1, + "articleId": 1, + "sku": "…", + "amount": 1, + "net": 1, + "rebate": 1, + "tax": 1, + "comment": "…" + } + ] + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid order, invalid lines, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company, entity, or article not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/order/delete.md b/docs/api/routes/order/delete.md new file mode 100644 index 0000000..85f7970 --- /dev/null +++ b/docs/api/routes/order/delete.md @@ -0,0 +1,32 @@ +# `DELETE /order/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete order + +Deletes the sales order (`manifestarticle` rows cascade). + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `204` + +Deleted + +_No content_ + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid id — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Order not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/order/get.md b/docs/api/routes/order/get.md new file mode 100644 index 0000000..b21c174 --- /dev/null +++ b/docs/api/routes/order/get.md @@ -0,0 +1,54 @@ +# `GET /order/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get order + +Returns a sales order with lines for the selected company. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Order + +```json +{ + "order": { + "id": 1, + "number": "…", + "type": "order", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z", + "lines": [ + { + "id": 1, + "articleId": 1, + "sku": "…", + "amount": 1, + "net": 1, + "rebate": 1, + "tax": 1, + "comment": "…" + } + ] + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid id — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Order or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/order/list.md b/docs/api/routes/order/list.md new file mode 100644 index 0000000..df4e208 --- /dev/null +++ b/docs/api/routes/order/list.md @@ -0,0 +1,40 @@ +# `GET /order` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List orders + +Lists sales orders (`manifest` rows with `type = 'order'`) +for the authenticated user's selected company. + + +## Response `200` + +Order list + +```json +{ + "orders": [ + { + "id": 1, + "number": "…", + "type": "order", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/order/update.md b/docs/api/routes/order/update.md new file mode 100644 index 0000000..ad16718 --- /dev/null +++ b/docs/api/routes/order/update.md @@ -0,0 +1,66 @@ +# `PATCH /order/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Update order + +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). + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Body (JSON) + +```json +{ + "number": "O-2026-001b", + "lines": [] +} +``` + +## Response `200` + +Updated order + +```json +{ + "order": { + "id": 1, + "number": "…", + "type": "order", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z", + "lines": [ + { + "id": 1, + "articleId": 1, + "sku": "…", + "amount": 1, + "net": 1, + "rebate": 1, + "tax": 1, + "comment": "…" + } + ] + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid order, invalid lines, invalid id, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Order, entity, article, or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/quote/create.md b/docs/api/routes/quote/create.md new file mode 100644 index 0000000..6596f02 --- /dev/null +++ b/docs/api/routes/quote/create.md @@ -0,0 +1,70 @@ +# `POST /quote` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Create quote + +Creates a quote with header and lines in one transaction. +`date` is the document date (valid-until). +`entityId` must be a company customer (not Lieferanten/POS), or null. +Each line requires `sku`, `amount`, `net`, and `tax` (rate); `rebate` defaults to `0`. + + +## Body (JSON) + +```json +{ + "number": "Q-2026-001", + "date": "2026-08-31", + "entityId": 1, + "lines": [ + { + "sku": "1", + "amount": 10, + "net": 99.5, + "tax": 19 + } + ] +} +``` + +## Response `201` + +Created quote + +```json +{ + "quote": { + "id": 1, + "number": "…", + "type": "quote", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z", + "lines": [ + { + "id": 1, + "articleId": 1, + "sku": "…", + "amount": 1, + "net": 1, + "rebate": 1, + "tax": 1, + "comment": "…" + } + ] + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid quote, invalid lines, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company, entity, or article not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/quote/delete.md b/docs/api/routes/quote/delete.md new file mode 100644 index 0000000..c26b592 --- /dev/null +++ b/docs/api/routes/quote/delete.md @@ -0,0 +1,32 @@ +# `DELETE /quote/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete quote + +Deletes the quote (`manifestarticle` rows cascade). + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `204` + +Deleted + +_No content_ + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid id — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Quote not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/quote/get.md b/docs/api/routes/quote/get.md new file mode 100644 index 0000000..67cb456 --- /dev/null +++ b/docs/api/routes/quote/get.md @@ -0,0 +1,54 @@ +# `GET /quote/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get quote + +Returns a quote with lines for the selected company. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Response `200` + +Quote + +```json +{ + "quote": { + "id": 1, + "number": "…", + "type": "quote", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z", + "lines": [ + { + "id": 1, + "articleId": 1, + "sku": "…", + "amount": 1, + "net": 1, + "rebate": 1, + "tax": 1, + "comment": "…" + } + ] + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected or invalid id — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Quote or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/quote/list.md b/docs/api/routes/quote/list.md new file mode 100644 index 0000000..ea40831 --- /dev/null +++ b/docs/api/routes/quote/list.md @@ -0,0 +1,40 @@ +# `GET /quote` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List quotes + +Lists quotes (`manifest` rows with `type = 'quote'`) +for the authenticated user's selected company. + + +## Response `200` + +Quote list + +```json +{ + "quotes": [ + { + "id": 1, + "number": "…", + "type": "quote", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z" + } + ] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/quote/update.md b/docs/api/routes/quote/update.md new file mode 100644 index 0000000..71e4608 --- /dev/null +++ b/docs/api/routes/quote/update.md @@ -0,0 +1,66 @@ +# `PATCH /quote/{id}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Update quote + +Patches header fields that are present. If `lines` is present, replaces +all lines; if omitted, leaves lines unchanged. Returns the full quote. +`date` is the document date (valid-until). + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | | + +## Body (JSON) + +```json +{ + "number": "Q-2026-001b", + "lines": [] +} +``` + +## Response `200` + +Updated quote + +```json +{ + "quote": { + "id": 1, + "number": "…", + "type": "quote", + "entityId": 1, + "date": "2026-08-10T00:00:00.000Z", + "lines": [ + { + "id": 1, + "articleId": 1, + "sku": "…", + "amount": 1, + "net": 1, + "rebate": 1, + "tax": 1, + "comment": "…" + } + ] + } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid quote, invalid lines, invalid id, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Quote, entity, article, or company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 89f4018..b4bb18b 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -38,6 +38,8 @@ const options = { { name: 'Brand', description: 'Company-scoped brands' }, { name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' }, { name: 'Delivery', description: 'Company-scoped goods receipts (Lieferungen)' }, + { name: 'Quote', description: 'Company-scoped quotes (Angebote)' }, + { name: 'Order', description: 'Company-scoped sales orders (Aufträge)' }, { name: 'Customergroup', description: 'Company-scoped customer groups' }, { name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' }, { name: 'Image', description: 'Content-addressed AVIF by fullHash' }, diff --git a/src/api/openapi.js b/src/api/openapi.js index 2be56a5..0811161 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -271,6 +271,108 @@ * type: array * items: * $ref: '#/components/schemas/DeliveryLine' + * ManifestLine: + * type: object + * required: [id, articleId, sku, amount, net, rebate, tax, comment] + * description: Line shape for quote and order (no transactedAmount) + * properties: + * id: + * type: integer + * articleId: + * type: integer + * nullable: true + * sku: + * type: string + * nullable: true + * amount: + * type: number + * net: + * type: number + * rebate: + * type: number + * tax: + * type: number + * description: tax.rate + * comment: + * type: string + * nullable: true + * ManifestLineInput: + * type: object + * required: [sku, amount, net, tax] + * properties: + * sku: + * type: string + * amount: + * type: number + * net: + * type: number + * tax: + * type: number + * description: Tax rate (resolved via tax.ensure) + * rebate: + * type: number + * description: Defaults to 0 + * comment: + * type: string + * nullable: true + * QuoteSummary: + * type: object + * required: [id, number, type, entityId, date] + * properties: + * id: + * type: integer + * number: + * type: string + * type: + * type: string + * enum: [quote] + * entityId: + * type: integer + * nullable: true + * description: Customer (manifest.entity_id) + * date: + * type: string + * format: date-time + * description: Document date / valid-until + * Quote: + * allOf: + * - $ref: '#/components/schemas/QuoteSummary' + * - type: object + * required: [lines] + * properties: + * lines: + * type: array + * items: + * $ref: '#/components/schemas/ManifestLine' + * OrderSummary: + * type: object + * required: [id, number, type, entityId, date] + * properties: + * id: + * type: integer + * number: + * type: string + * type: + * type: string + * enum: [order] + * entityId: + * type: integer + * nullable: true + * description: Customer (manifest.entity_id) + * date: + * type: string + * format: date-time + * description: Document date / expected delivery + * Order: + * allOf: + * - $ref: '#/components/schemas/OrderSummary' + * - type: object + * required: [lines] + * properties: + * lines: + * type: array + * items: + * $ref: '#/components/schemas/ManifestLine' * ArticleImage: * type: object * required: [picId, sort, full, thumb, hash, thumbHash] diff --git a/src/api/routes/order.js b/src/api/routes/order.js new file mode 100644 index 0000000..4fcf305 --- /dev/null +++ b/src/api/routes/order.js @@ -0,0 +1,433 @@ +import * as db from '../../db/order.js'; + +function requireSelectedCompany(req) { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function orderErrorStatus(error) { + 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 === 'Entity not found') return 404; + if (error.message === 'Article not found') return 404; + if (error.message === 'Invalid order') return 400; + if (error.message === 'Invalid lines') return 400; + return 500; +} + +/** + * 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. + * security: + * - cookieAuth: [] + * x-markdown: order/list.md + * responses: + * 200: + * description: Order list + * content: + * application/json: + * schema: + * type: object + * properties: + * orders: + * type: array + * items: + * $ref: '#/components/schemas/OrderSummary' + * 400: + * description: 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/POS), or null. + * Each line requires `sku`, `amount`, `net`, and `tax` (rate); `rebate` defaults to `0`. + * security: + * - cookieAuth: [] + * x-markdown: order/create.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [number, date, lines] + * properties: + * number: + * type: string + * date: + * type: string + * format: date-time + * description: Document date / expected delivery + * entityId: + * type: integer + * nullable: true + * description: Customer entity (manifest.entity_id) + * lines: + * type: array + * items: + * $ref: '#/components/schemas/ManifestLineInput' + * example: + * number: O-2026-001 + * date: '2026-09-15' + * entityId: 1 + * lines: + * - sku: '1' + * amount: 10 + * net: 99.5 + * tax: 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' + */ + app.get(`${basePath}/order`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const orders = await db.list(req.user.id, selected.companyId); + res.status(200).json({ orders }); + } catch (error) { + const status = orderErrorStatus(error); + if (status === 500) console.error('List orders failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + 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, entityId, lines } = req.body ?? {}; + + try { + const order = await db.create(req.user.id, selected.companyId, { + number, + date, + entityId, + 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 }); + } + }); + + /** + * @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 + * 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' + */ + 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, selected.companyId, 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 }); + } + }); + + 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, 'entityId')) { + patch.entityId = body.entityId; + } + if (Object.prototype.hasOwnProperty.call(body, 'lines')) { + patch.lines = body.lines; + } + + try { + const order = await db.update(req.user.id, selected.companyId, 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 }); + } + }); + + 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, selected.companyId, 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 }); + } + }); +} diff --git a/src/api/routes/quote.js b/src/api/routes/quote.js new file mode 100644 index 0000000..68a1a5f --- /dev/null +++ b/src/api/routes/quote.js @@ -0,0 +1,433 @@ +import * as db from '../../db/quote.js'; + +function requireSelectedCompany(req) { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return { error: 'No company selected' }; + } + return { companyId }; +} + +function quoteErrorStatus(error) { + if (error.message === 'Not authorized') return 403; + if (error.message === 'Company not found') return 404; + if (error.message === 'Quote not found') return 404; + if (error.message === 'Entity not found') return 404; + if (error.message === 'Article not found') return 404; + if (error.message === 'Invalid quote') return 400; + if (error.message === 'Invalid lines') return 400; + return 500; +} + +/** + * Registers quote routes for the authenticated user's selected company. + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /quote: + * get: + * tags: [Quote] + * summary: List quotes + * description: | + * Lists quotes (`manifest` rows with `type = 'quote'`) + * for the authenticated user's selected company. + * security: + * - cookieAuth: [] + * x-markdown: quote/list.md + * responses: + * 200: + * description: Quote list + * content: + * application/json: + * schema: + * type: object + * properties: + * quotes: + * type: array + * items: + * $ref: '#/components/schemas/QuoteSummary' + * 400: + * description: 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: [Quote] + * summary: Create quote + * description: | + * Creates a quote with header and lines in one transaction. + * `date` is the document date (valid-until). + * `entityId` must be a company customer (not Lieferanten/POS), or null. + * Each line requires `sku`, `amount`, `net`, and `tax` (rate); `rebate` defaults to `0`. + * security: + * - cookieAuth: [] + * x-markdown: quote/create.md + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [number, date, lines] + * properties: + * number: + * type: string + * date: + * type: string + * format: date-time + * description: Document date / valid-until + * entityId: + * type: integer + * nullable: true + * description: Customer entity (manifest.entity_id) + * lines: + * type: array + * items: + * $ref: '#/components/schemas/ManifestLineInput' + * example: + * number: Q-2026-001 + * date: '2026-08-31' + * entityId: 1 + * lines: + * - sku: '1' + * amount: 10 + * net: 99.5 + * tax: 19 + * responses: + * 201: + * description: Created quote + * content: + * application/json: + * schema: + * type: object + * properties: + * quote: + * $ref: '#/components/schemas/Quote' + * 400: + * description: Invalid quote, 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' + */ + app.get(`${basePath}/quote`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + try { + const quotes = await db.list(req.user.id, selected.companyId); + res.status(200).json({ quotes }); + } catch (error) { + const status = quoteErrorStatus(error); + if (status === 500) console.error('List quotes failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + app.post(`${basePath}/quote`, async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const { number, date, entityId, lines } = req.body ?? {}; + + try { + const quote = await db.create(req.user.id, selected.companyId, { + number, + date, + entityId, + lines, + }); + res.status(201).json({ quote }); + } catch (error) { + const status = quoteErrorStatus(error); + if (status === 500) console.error('Create quote failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /quote/{id}: + * get: + * tags: [Quote] + * summary: Get quote + * description: Returns a quote with lines for the selected company. + * security: + * - cookieAuth: [] + * x-markdown: quote/get.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Quote + * content: + * application/json: + * schema: + * type: object + * properties: + * quote: + * $ref: '#/components/schemas/Quote' + * 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: Quote or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * patch: + * tags: [Quote] + * summary: Update quote + * description: | + * Patches header fields that are present. If `lines` is present, replaces + * all lines; if omitted, leaves lines unchanged. Returns the full quote. + * `date` is the document date (valid-until). + * security: + * - cookieAuth: [] + * x-markdown: quote/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 / valid-until + * entityId: + * type: integer + * nullable: true + * lines: + * type: array + * items: + * $ref: '#/components/schemas/ManifestLineInput' + * example: + * number: Q-2026-001b + * lines: [] + * responses: + * 200: + * description: Updated quote + * content: + * application/json: + * schema: + * type: object + * properties: + * quote: + * $ref: '#/components/schemas/Quote' + * 400: + * description: Invalid quote, 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: Quote, entity, article, or company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * delete: + * tags: [Quote] + * summary: Delete quote + * description: Deletes the quote (`manifestarticle` rows cascade). + * security: + * - cookieAuth: [] + * x-markdown: quote/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: Quote not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/quote/: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 quote = await db.getById(req.user.id, selected.companyId, id); + res.status(200).json({ quote }); + } catch (error) { + const status = quoteErrorStatus(error); + if (status === 500) console.error('Get quote failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + app.patch(`${basePath}/quote/: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, 'entityId')) { + patch.entityId = body.entityId; + } + if (Object.prototype.hasOwnProperty.call(body, 'lines')) { + patch.lines = body.lines; + } + + try { + const quote = await db.update(req.user.id, selected.companyId, id, patch); + res.status(200).json({ quote }); + } catch (error) { + const status = quoteErrorStatus(error); + if (status === 500) console.error('Update quote failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + app.delete(`${basePath}/quote/: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, selected.companyId, id); + res.sendStatus(204); + } catch (error) { + const status = quoteErrorStatus(error); + if (status === 500) console.error('Delete quote failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} diff --git a/src/db/order.js b/src/db/order.js new file mode 100644 index 0000000..5651824 --- /dev/null +++ b/src/db/order.js @@ -0,0 +1,26 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId, companyId) { + const store = await getStore(); + return store.order.list(googleAuthId, companyId); +} + +export async function getById(googleAuthId, companyId, id) { + const store = await getStore(); + return store.order.getById(googleAuthId, companyId, id); +} + +export async function create(googleAuthId, companyId, data) { + const store = await getStore(); + return store.order.create(googleAuthId, companyId, data); +} + +export async function update(googleAuthId, companyId, id, data) { + const store = await getStore(); + return store.order.update(googleAuthId, companyId, id, data); +} + +export async function remove(googleAuthId, companyId, id) { + const store = await getStore(); + return store.order.delete(googleAuthId, companyId, id); +} diff --git a/src/db/quote.js b/src/db/quote.js new file mode 100644 index 0000000..2039386 --- /dev/null +++ b/src/db/quote.js @@ -0,0 +1,26 @@ +import { getStore } from './store.js'; + +export async function list(googleAuthId, companyId) { + const store = await getStore(); + return store.quote.list(googleAuthId, companyId); +} + +export async function getById(googleAuthId, companyId, id) { + const store = await getStore(); + return store.quote.getById(googleAuthId, companyId, id); +} + +export async function create(googleAuthId, companyId, data) { + const store = await getStore(); + return store.quote.create(googleAuthId, companyId, data); +} + +export async function update(googleAuthId, companyId, id, data) { + const store = await getStore(); + return store.quote.update(googleAuthId, companyId, id, data); +} + +export async function remove(googleAuthId, companyId, id) { + const store = await getStore(); + return store.quote.delete(googleAuthId, companyId, id); +} diff --git a/src/db/store.js b/src/db/store.js index 167cb9c..e1c633e 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -25,6 +25,8 @@ const STORE_NAMESPACES = [ 'entity', 'entityaddress', 'entitycontact', + 'quote', + 'order', ]; /**