From fde381937ca27335e7c3b17c28a8b7016dca4d8b Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 20 Oct 2026 05:33:14 +0200 Subject: [PATCH] u --- docs/api/README.md | 11 ++-- docs/api/routes-all.md | 7 ++- docs/api/routes.md | 7 ++- docs/api/routes/order/_id/shipment.post.md | 11 ++-- docs/api/routes/shipment.md | 64 ++++++++++++++++++++++ docs/api/routes/shipment/_id.delete.md | 8 ++- src/api/routes/shipment.js | 54 +++++++++++++----- src/api/routes/stock.js | 3 +- src/db/store.js | 1 + 9 files changed, 137 insertions(+), 29 deletions(-) create mode 100644 docs/api/routes/shipment.md diff --git a/docs/api/README.md b/docs/api/README.md index a7f08fc..1f77c93 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -242,6 +242,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/quote/_id/pdf.md`](routes/quote/_id/pdf.md) | `POST /quote/{id}/pdf` | | [`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` | | [`routes/shipment/_id.delete.md`](routes/shipment/_id.delete.md) | `DELETE /shipment/{id}` | | [`routes/shipment/_id.get.md`](routes/shipment/_id.get.md) | `GET /shipment/{id}` | | [`routes/shipment/_id.patch.md`](routes/shipment/_id.patch.md) | `PATCH /shipment/{id}` | @@ -305,23 +306,23 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | Signal | Emitted by | |--------|------------| | `article-` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight`, `POST /stock/adjust` | -| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | +| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | | `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` | | `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` | | `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` | | `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` | | `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` | -| `ledger` | `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | +| `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | | `modelconversation` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | | `modelconversation-` | `POST /modelconversation/{id}/message` | -| `order` | `DELETE /order/{id}/file/{fileId}`, `POST /order/{id}/pdf`, `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}` | +| `order` | `DELETE /order/{id}/file/{fileId}`, `POST /order/{id}/pdf`, `DELETE /shipment/{id}`, `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}` | | `printer` | `POST /printer`, `PUT /printer/order`, `DELETE /printer/{id}`, `PATCH /printer/{id}`, `PUT /printer/{id}` | | `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` | | `quote` | `DELETE /quote/{id}/file/{fileId}`, `POST /quote/{id}/pdf` | -| `shipment` | `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}`, `POST /shipment/{id}/pdf` | +| `shipment` | `DELETE /shipment/{id}`, `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}`, `POST /shipment/{id}/pdf` | | `shipping` | `POST /stock/ship` | -| `stock` | `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | +| `stock` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | | `subagentschedule` | `POST /subagentschedule`, `DELETE /subagentschedule/{id}`, `PUT /subagentschedule/{id}` | | `vfs` | `POST /vfs/applets/restore`, `POST /vfs/mkdir`, `POST /vfs/rename`, `POST /vfs/skills/restore`, `POST /vfs/upload`, `DELETE /vfs/{*path}`, `POST /vfs/{*path}`, `PUT /vfs/{*path}` | | `warehouse` | `POST /warehouse`, `PUT /warehouse/order`, `DELETE /warehouse/{id}`, `PUT /warehouse/{id}` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 9e89b8f..e6fa21a 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 263 API endpoints across all modules. +Overview of all 264 API endpoints across all modules. ### Notation @@ -653,7 +653,7 @@ _none_ - Params: `id: int` - Returns: `200 { shipments: T3[] }` #### Create shipment for an order — `POST /order/{id}/shipment` -- Params: `id: int, body?: { date?: str, externalNumber?: str, lines?: { sku: str, amount?: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[] }` +- Params: `id: int, body: { lines: { sku: str, amount?: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, warehouseId?: int, externalNumber?: str }` - Returns: `201 { shipment: T3 }` #### List shippings of an order — `GET /order/{id}/shipping` - Params: `id: int` @@ -805,6 +805,9 @@ _none_ ### Section: /shipment +#### List shipments — `GET /shipment` +- Params: `query: offset?: int, limit?: int, search?: str, state?: str, seekMonths?: int` +- Returns: `200 { shipments: { id: int, number: str, type: str, date: str, positions: int, totalQuantity: num, packageCount: int, state: str, entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, orderId?: int|null, orderNumber?: str|null, customerName?: str|null, carrier?: str|null, trackingCode?: str|null }[], total: int, unfilteredTotal: int, offset: int, limit: int }` #### Delete shipment — `DELETE /shipment/{id}` - Params: `id: int` - Returns: `204 void` diff --git a/docs/api/routes.md b/docs/api/routes.md index 79fff12..37736e3 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 186 API endpoints across all modules. +Overview of all 187 API endpoints across all modules. ### Notation @@ -466,7 +466,7 @@ _none_ - Params: `id: int` - Returns: `200 { shipments: T3[] }` #### Create shipment for an order — `POST /order/{id}/shipment` -- Params: `id: int, body?: { date?: str, externalNumber?: str, lines?: { sku: str, amount?: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[] }` +- Params: `id: int, body: { lines: { sku: str, amount?: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, warehouseId?: int, externalNumber?: str }` - Returns: `201 { shipment: T3 }` #### List shippings of an order — `GET /order/{id}/shipping` - Params: `id: int` @@ -558,6 +558,9 @@ _none_ ### Section: /shipment +#### List shipments — `GET /shipment` +- Params: `query: offset?: int, limit?: int, search?: str, state?: str, seekMonths?: int` +- Returns: `200 { shipments: { id: int, number: str, type: str, date: str, positions: int, totalQuantity: num, packageCount: int, state: str, entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, orderId?: int|null, orderNumber?: str|null, customerName?: str|null, carrier?: str|null, trackingCode?: str|null }[], total: int, unfilteredTotal: int, offset: int, limit: int }` #### Delete shipment — `DELETE /shipment/{id}` - Params: `id: int` - Returns: `204 void` diff --git a/docs/api/routes/order/_id/shipment.post.md b/docs/api/routes/order/_id/shipment.post.md index 23747af..74a25d7 100644 --- a/docs/api/routes/order/_id/shipment.post.md +++ b/docs/api/routes/order/_id/shipment.post.md @@ -8,8 +8,9 @@ Creates a new shipment manifest (Lieferschein) for an order containing one, some If `lines` is omitted or empty, all remaining unshipped items from the order are included automatically. On individual line items, `amount` is optional and defaults to the remaining unshipped quantity for that article. Auto-assigns the shipment number formatted as ``, e.g. `AUF-000001-L-1`. +Automatically books shipment lines out on the ledger and deducts warehouse stock for stock-tracked articles. Updates the materialized `ordersummary` table (`shipped_quantity`, `is_shipped`) and emits SSE events -(`order`, `shipment`) so that the order list and summary tables are rerendered in real time. +(`order`, `shipment`, `stock`, `ledger`, `articles`) so that inventory and summaries are rerendered in real time. ## Path parameters @@ -18,13 +19,14 @@ Updates the materialized `ordersummary` table (`shipped_quantity`, `is_shipped`) |-------|----------|------|-------------| | `id` | yes | integer | Order ID | -## Body (JSON) — _optional_ +## Body (JSON) | Field | Required | Type | Description | |-------|----------|------|-------------| | `date` | no | string | Optional shipment date (defaults to current date) | +| `warehouseId` | no | integer | Optional target warehouse ID to deduct stock from (defaults to company default warehouse) | | `externalNumber` | no | string | Optional external delivery note reference (defaults to order.externalNumber or order.number) | -| `lines` | no | object[] | Optional positions to ship. If omitted, all remaining unshipped items are shipped. | +| `lines` | yes | object[] | Positions to ship | | `lines[].sku` | yes | string | Article SKU | | `lines[].amount` | no | number | Quantity to ship. Defaults to remaining unshipped quantity for this SKU. | | `lines[].name` | no | string | | @@ -37,6 +39,7 @@ Updates the materialized `ordersummary` table (`shipped_quantity`, `is_shipped`) ```json { "date": "2026-08-10", + "warehouseId": 1, "externalNumber": "…", "lines": [ { @@ -124,7 +127,7 @@ Created shipment | 400 | Validation error or article not in order — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 404 | Order not found — `{"error":"…"}` | -| 409 | Cumulative shipment amount exceeds ordered amount, or all items are already shipped — `{"error":"…"}` | +| 409 | Cumulative shipment amount exceeds ordered amount, or insufficient stock — `{"error":"…"}` | --- diff --git a/docs/api/routes/shipment.md b/docs/api/routes/shipment.md new file mode 100644 index 0000000..c0c2f3c --- /dev/null +++ b/docs/api/routes/shipment.md @@ -0,0 +1,64 @@ +# `GET /shipment` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +List shipments + +Company-wide paginated list of shipment manifests (`manifest` rows with `type = 'shipment'`) +with delivery state, entityId, package tracking info, and order references. + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `offset` | no | integer | Number of shipments to skip | +| `limit` | no | integer | Number of shipments to return | +| `search` | no | string | Free-text search matching shipment number, order number, external number, tracking code, or customer name | +| `state` | no | string | Optional filter by delivery state (cancelled shipments hidden by default) | +| `seekMonths` | no | integer | Negative integer for cutoff months ago (e.g. -1 for shipments older than 1 month) | + +## Response `200` + +Paginated shipment list + +```json +{ + "shipments": [ + { + "id": 1, + "number": "…", + "type": "shipment", + "entityId": 1, + "externalNumber": "…", + "date": "…", + "createdAt": "…", + "orderId": 1, + "orderNumber": "…", + "customerName": "…", + "positions": 1, + "totalQuantity": 1, + "packageCount": 1, + "carrier": "…", + "trackingCode": "…", + "state": "new" + } + ], + "total": 1, + "unfilteredTotal": 1, + "offset": 1, + "limit": 1 +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid query parameters or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/shipment/_id.delete.md b/docs/api/routes/shipment/_id.delete.md index a525e32..293404f 100644 --- a/docs/api/routes/shipment/_id.delete.md +++ b/docs/api/routes/shipment/_id.delete.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Delete shipment -Deletes an unshipped shipment manifest. Shipped shipments cannot be deleted. +Deletes an unshipped shipment manifest or cancels a shipped shipment restoring stock. Shipped packages cannot be deleted. ## Path parameters @@ -12,6 +12,10 @@ Deletes an unshipped shipment manifest. Shipped shipments cannot be deleted. |-------|----------|------|-------------| | `id` | yes | integer | Shipment ID | +## Signals + +On success, emits SSE signals: `shipment`, `order`, `stock`, `ledger`, `articles` (see [`GET /events`](../events.md)). + ## Response `204` Shipment deleted @@ -25,7 +29,7 @@ _No content_ | 400 | Invalid ID or no company selected — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` | | 404 | Shipment not found — `{"error":"…"}` | -| 409 | Shipped shipment cannot be deleted — `{"error":"…"}` | +| 409 | Shipped shipment cannot be deleted or shipment already cancelled — `{"error":"…"}` | --- diff --git a/src/api/routes/shipment.js b/src/api/routes/shipment.js index 8aa7569..de50ab7 100644 --- a/src/api/routes/shipment.js +++ b/src/api/routes/shipment.js @@ -1,4 +1,5 @@ import * as db from '../../db/shipment.js'; +import * as dbStock from '../../db/stock.js'; import { createAndAttachManifestPdf } from '../../services/manifestPdf.js'; import { emit } from '../sse.js'; @@ -37,10 +38,12 @@ function shipmentErrorStatus(error) { } if ( error.message?.startsWith('Shipment amount exceeds') || + error.message?.startsWith('Insufficient stock') || error.message === 'Shipment number already exists' || error.message?.includes('cannot be deleted') || error.message?.includes('cannot be undone') || - error.message?.includes('already shipped') + error.message?.includes('already shipped') || + error.message?.includes('already cancelled') ) { return 409; } @@ -143,8 +146,9 @@ export function register(app, basePath = '') { * If `lines` is omitted or empty, all remaining unshipped items from the order are included automatically. * On individual line items, `amount` is optional and defaults to the remaining unshipped quantity for that article. * Auto-assigns the shipment number formatted as ``, e.g. `AUF-000001-L-1`. + * Automatically books shipment lines out on the ledger and deducts warehouse stock for stock-tracked articles. * Updates the materialized `ordersummary` table (`shipped_quantity`, `is_shipped`) and emits SSE events - * (`order`, `shipment`) so that the order list and summary tables are rerendered in real time. + * (`order`, `shipment`, `stock`, `ledger`, `articles`) so that inventory and summaries are rerendered in real time. * security: * - cookieAuth: [] * parameters: @@ -155,22 +159,26 @@ export function register(app, basePath = '') { * type: integer * description: Order ID * requestBody: - * required: false + * required: true * content: * application/json: * schema: * type: object + * required: [lines] * properties: * date: * type: string * format: date * description: Optional shipment date (defaults to current date) + * warehouseId: + * type: integer + * description: Optional target warehouse ID to deduct stock from (defaults to company default warehouse) * externalNumber: * type: string * description: Optional external delivery note reference (defaults to order.externalNumber or order.number) * lines: * type: array - * description: Optional positions to ship. If omitted, all remaining unshipped items are shipped. + * description: Positions to ship * items: * type: object * required: [sku] @@ -222,7 +230,7 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' * 409: - * description: Cumulative shipment amount exceeds ordered amount, or all items are already shipped + * description: Cumulative shipment amount exceeds ordered amount, or insufficient stock * content: * application/json: * schema: @@ -246,7 +254,24 @@ export function register(app, basePath = '') { ...(req.body ?? {}), orderId: parsedId.id, }); + + if (shipment?.id) { + try { + await dbStock.ship(req.user.id, { + shipmentId: shipment.id, + warehouseId: req.body?.warehouseId, + }); + } catch (shipErr) { + await db.remove(req.user.id, shipment.id).catch(() => {}); + throw shipErr; + } + } + emitShipmentEvents(selected.companyId); + emit('stock', { companyId: selected.companyId }); + emit('ledger', { companyId: selected.companyId }); + emit('articles', { companyId: selected.companyId }); + res.status(201).json({ shipment }); } catch (error) { const status = shipmentErrorStatus(error); @@ -265,8 +290,7 @@ export function register(app, basePath = '') { * summary: List shipments * description: | * Company-wide paginated list of shipment manifests (`manifest` rows with `type = 'shipment'`) - * with delivery state, - entityId, package tracking info, and order references. + * with delivery state, entityId, package tracking info, and order references. * security: * - cookieAuth: [] * parameters: @@ -294,8 +318,8 @@ export function register(app, basePath = '') { * name: state * schema: * type: string - * enum: [new, delivered, returned] - * description: Optional filter by delivery state + * enum: [new, delivered, returned, cancelled, all] + * description: Optional filter by delivery state (cancelled shipments hidden by default) * - in: query * name: seekMonths * schema: @@ -376,7 +400,7 @@ export function register(app, basePath = '') { let state = undefined; if (typeof req.query.state === 'string' && req.query.state.trim() !== '') { const trimmedState = req.query.state.trim().toLowerCase(); - if (!['new', 'delivered', 'returned'].includes(trimmedState)) { + if (!['new', 'delivered', 'returned', 'cancelled', 'all'].includes(trimmedState)) { return res.status(400).json({ error: 'Invalid state' }); } state = trimmedState; @@ -507,7 +531,7 @@ export function register(app, basePath = '') { * properties: * state: * type: string - * enum: [new, delivered, returned] + * enum: [new, delivered, returned, cancelled] * description: Delivery state * example: * state: delivered @@ -581,7 +605,8 @@ export function register(app, basePath = '') { * delete: * tags: [Shipment] * summary: Delete shipment - * description: Deletes an unshipped shipment manifest. Shipped shipments cannot be deleted. + * description: Deletes an unshipped shipment manifest or cancels a shipped shipment restoring stock. Shipped packages cannot be deleted. + * x-signals: [shipment, order, stock, ledger, articles] * security: * - cookieAuth: [] * parameters: @@ -613,7 +638,7 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' * 409: - * description: Shipped shipment cannot be deleted + * description: Shipped shipment cannot be deleted or shipment already cancelled * content: * application/json: * schema: @@ -635,6 +660,9 @@ export function register(app, basePath = '') { try { await db.remove(req.user.id, parsedId.id); emitShipmentEvents(selected.companyId); + emit('stock', { companyId: selected.companyId }); + emit('ledger', { companyId: selected.companyId }); + emit('articles', { companyId: selected.companyId }); res.status(204).end(); } catch (error) { const status = shipmentErrorStatus(error); diff --git a/src/api/routes/stock.js b/src/api/routes/stock.js index 10efd46..1417c89 100644 --- a/src/api/routes/stock.js +++ b/src/api/routes/stock.js @@ -19,7 +19,8 @@ function stockErrorStatus(error) { error.message?.includes('already accepted') || error.message?.includes('already shipped') || error.message?.includes('already booked') || - error.message?.includes('Stock tracking is disabled') + error.message?.includes('Stock tracking is disabled') || + error.message?.startsWith('Insufficient stock') ) { return 409; } diff --git a/src/db/store.js b/src/db/store.js index 3a0fc65..86f7f32 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -30,6 +30,7 @@ const STORE_NAMESPACES = [ 'entitycontact', 'quote', 'order', + 'shipment', 'numbers', 'companysetting', 'articlesupplier',