This commit is contained in:
seb
2026-09-17 11:46:22 +02:00
parent ab6d018a1f
commit 49efd5f8a1
23 changed files with 1384 additions and 79 deletions

View File

@@ -0,0 +1,487 @@
import * as db from '../../db/articlesupplier.js';
import { emit } from '../sse.js';
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function parseSku(value) {
const sku = typeof value === 'string' ? value.trim() : '';
if (!sku) return { error: 'sku is required' };
return { sku: decodeURIComponent(sku) };
}
function parsePositiveInt(value, field) {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
return { error: `${field} is required` };
}
return { id };
}
function emitArticleSignal(req, sku) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) return;
emit(`article-${sku}`, { companyId });
}
function articleSupplierErrorStatus(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 === 'Article not found') return 404;
if (error.message === 'Supplier not found') return 404;
if (error.message === 'Supplier assignment not found') return 404;
if (error.message === 'Supplier already assigned') return 409;
if (error.message === 'Supplier required' || error.message === 'entityId is required') return 400;
if (error.message === 'Invalid pu' || error.message === 'pu must be greater than 0') return 400;
if (error.message === 'Invalid stock') return 400;
if (error.message === 'Invalid price') return 400;
if (error.message === 'sku is required') return 400;
if (error.message === 'id is required') return 400;
return 500;
}
/**
* Registers article supplier routes under `/article/:sku/supplier`.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /article/{sku}/supplier:
* get:
* tags: [Article]
* summary: List article suppliers
* description: Fetches all suppliers linked to the specified article SKU.
* security:
* - cookieAuth: []
* x-markdown: article/supplier/list.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* responses:
* 200:
* description: List of suppliers linked to the article
* content:
* application/json:
* schema:
* type: object
* properties:
* suppliers:
* type: array
* items:
* $ref: '#/components/schemas/ArticleSupplier'
* 400:
* description: No company selected or invalid sku
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Article or company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* post:
* tags: [Article]
* summary: Assign supplier to article
* description: |
* Assigns a supplier to an article with optional purchase price, stock, and supplier SKU.
* security:
* - cookieAuth: []
* x-signals: [article-<sku>]
* x-markdown: article/supplier/create.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [entityId]
* properties:
* entityId:
* type: integer
* description: Supplier entity ID (in Lieferanten group)
* sku:
* type: string
* nullable: true
* description: Supplier's item number
* pu:
* type: number
* default: 1
* description: Packaging unit (VPE)
* stock:
* type: number
* nullable: true
* description: Supplier stock
* netPrice:
* type: number
* nullable: true
* description: Net purchase price
* taxId:
* type: integer
* nullable: true
* description: Tax ID for purchase price
* taxRate:
* type: number
* nullable: true
* description: Tax rate in percent
* example:
* entityId: 88
* sku: MET-44910
* pu: 1
* stock: 140
* netPrice: 4.20
* taxId: 1
* responses:
* 201:
* description: Created supplier assignment
* content:
* application/json:
* schema:
* type: object
* properties:
* supplier:
* $ref: '#/components/schemas/ArticleSupplier'
* 400:
* description: entityId required, invalid pu, invalid stock, invalid price, or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Article, supplier, or company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Supplier already assigned
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/article/:sku/supplier`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
try {
const suppliers = await db.listByArticleSku(req.user.id, skuParsed.sku);
res.status(200).json({ suppliers });
} catch (error) {
const status = articleSupplierErrorStatus(error);
if (status === 500) console.error('List article suppliers failed:', error);
res.status(status).json({ error: error.message });
}
});
app.post(`${basePath}/article/:sku/supplier`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
const { entityId, sku: supplierSku, pu, stock, netPrice, taxId, taxRate } = req.body ?? {};
if (entityId == null || !Number.isFinite(entityId)) {
return res.status(400).json({ error: 'entityId is required' });
}
if (pu !== undefined && pu !== null && (!Number.isFinite(pu) || pu <= 0)) {
return res.status(400).json({ error: 'pu must be greater than 0' });
}
if (stock !== undefined && stock !== null && !Number.isFinite(stock)) {
return res.status(400).json({ error: 'Invalid stock' });
}
if (netPrice !== undefined && netPrice !== null && !Number.isFinite(netPrice)) {
return res.status(400).json({ error: 'Invalid price' });
}
try {
const supplier = await db.create(req.user.id, skuParsed.sku, {
entityId,
sku: supplierSku,
pu,
stock,
netPrice,
taxId,
taxRate,
});
emitArticleSignal(req, skuParsed.sku);
res.status(201).json({ supplier });
} catch (error) {
const status = articleSupplierErrorStatus(error);
if (status === 500) console.error('Create article supplier failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/supplier/{id}:
* patch:
* tags: [Article]
* summary: Update article supplier assignment
* description: |
* Updates an existing article-supplier association (supplier SKU, packaging unit, stock, purchase price).
* security:
* - cookieAuth: []
* x-signals: [article-<sku>]
* x-markdown: article/supplier/update.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: articlesupplier assignment ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sku:
* type: string
* nullable: true
* pu:
* type: number
* stock:
* type: number
* nullable: true
* netPrice:
* type: number
* nullable: true
* taxId:
* type: integer
* nullable: true
* taxRate:
* type: number
* nullable: true
* example:
* sku: MET-44910-B
* pu: 6
* stock: 95
* netPrice: 22.50
* responses:
* 200:
* description: Updated supplier assignment
* content:
* application/json:
* schema:
* type: object
* properties:
* supplier:
* $ref: '#/components/schemas/ArticleSupplier'
* 400:
* description: Invalid pu, invalid stock, invalid price, 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: Supplier assignment, article, or company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* delete:
* tags: [Article]
* summary: Remove supplier assignment from article
* description: |
* Removes a supplier assignment from the article and cleans up its associated purchase price.
* security:
* - cookieAuth: []
* x-signals: [article-<sku>]
* x-markdown: article/supplier/delete.md
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Article SKU
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: articlesupplier assignment ID
* responses:
* 204:
* description: Deleted
* 400:
* description: Invalid id, invalid sku, 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: Supplier assignment or article not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.patch(`${basePath}/article/:sku/supplier/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
const idParsed = parsePositiveInt(req.params.id, 'id');
if (idParsed.error) {
return res.status(400).json({ error: idParsed.error });
}
const body = req.body ?? {};
const patch = {};
if (body.sku !== undefined) patch.sku = body.sku;
if (body.pu !== undefined) {
if (body.pu === null || !Number.isFinite(body.pu) || body.pu <= 0) {
return res.status(400).json({ error: 'pu must be greater than 0' });
}
patch.pu = body.pu;
}
if (body.stock !== undefined) {
if (body.stock !== null && !Number.isFinite(body.stock)) {
return res.status(400).json({ error: 'Invalid stock' });
}
patch.stock = body.stock;
}
if (body.netPrice !== undefined) {
if (body.netPrice !== null && !Number.isFinite(body.netPrice)) {
return res.status(400).json({ error: 'Invalid price' });
}
patch.netPrice = body.netPrice;
}
if (body.taxId !== undefined) patch.taxId = body.taxId;
if (body.taxRate !== undefined) patch.taxRate = body.taxRate;
try {
const supplier = await db.update(req.user.id, skuParsed.sku, idParsed.id, patch);
emitArticleSignal(req, skuParsed.sku);
res.status(200).json({ supplier });
} catch (error) {
const status = articleSupplierErrorStatus(error);
if (status === 500) console.error('Update article supplier failed:', error);
res.status(status).json({ error: error.message });
}
});
app.delete(`${basePath}/article/:sku/supplier/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const skuParsed = parseSku(req.params.sku);
if (skuParsed.error) {
return res.status(400).json({ error: skuParsed.error });
}
const idParsed = parsePositiveInt(req.params.id, 'id');
if (idParsed.error) {
return res.status(400).json({ error: idParsed.error });
}
try {
await db.remove(req.user.id, skuParsed.sku, idParsed.id);
emitArticleSignal(req, skuParsed.sku);
res.sendStatus(204);
} catch (error) {
const status = articleSupplierErrorStatus(error);
if (status === 500) console.error('Delete article supplier failed:', error);
res.status(status).json({ error: error.message });
}
});
}