637 lines
23 KiB
JavaScript
637 lines
23 KiB
JavaScript
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 === 'Invalid supplierId') return 400;
|
|
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 sort') return 400;
|
|
if (error.message === 'Invalid supplier order') 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;
|
|
if (error.message === 'ids 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
|
|
* sort:
|
|
* type: integer
|
|
* nullable: true
|
|
* description: Display sort order (defaults to next sort)
|
|
* 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
|
|
* sort: 0
|
|
* 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, sort, netPrice, taxId, taxRate } = req.body ?? {};
|
|
if (entityId == null) {
|
|
return res.status(400).json({ error: 'entityId is required' });
|
|
}
|
|
if (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0) {
|
|
return res.status(400).json({ error: 'entityId must be an integer' });
|
|
}
|
|
if (pu !== undefined && pu !== null && (typeof pu !== 'number' || !Number.isFinite(pu) || pu <= 0)) {
|
|
return res.status(400).json({ error: 'pu must be greater than 0' });
|
|
}
|
|
if (stock !== undefined && stock !== null && (typeof stock !== 'number' || !Number.isFinite(stock))) {
|
|
return res.status(400).json({ error: 'Invalid stock' });
|
|
}
|
|
if (sort !== undefined && sort !== null && (typeof sort !== 'number' || !Number.isInteger(sort) || sort < 0)) {
|
|
return res.status(400).json({ error: 'Invalid sort' });
|
|
}
|
|
if (netPrice !== undefined && netPrice !== null && (typeof netPrice !== 'number' || !Number.isFinite(netPrice) || netPrice < 0)) {
|
|
return res.status(400).json({ error: 'Invalid price' });
|
|
}
|
|
if (taxId !== undefined && taxId !== null && (typeof taxId !== 'number' || !Number.isInteger(taxId) || taxId <= 0)) {
|
|
return res.status(400).json({ error: 'Invalid taxId' });
|
|
}
|
|
if (taxRate !== undefined && taxRate !== null && (typeof taxRate !== 'number' || !Number.isFinite(taxRate) || taxRate < 0)) {
|
|
return res.status(400).json({ error: 'Invalid taxRate' });
|
|
}
|
|
|
|
try {
|
|
const supplier = await db.create(req.user.id, skuParsed.sku, {
|
|
entityId,
|
|
sku: supplierSku,
|
|
pu,
|
|
stock,
|
|
sort,
|
|
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
|
|
* sort:
|
|
* type: integer
|
|
* description: Display sort order
|
|
* 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.sort !== undefined) {
|
|
if (body.sort === null || !Number.isInteger(body.sort) || body.sort < 0) {
|
|
return res.status(400).json({ error: 'Invalid sort' });
|
|
}
|
|
patch.sort = body.sort;
|
|
}
|
|
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) {
|
|
if (body.taxId !== null && (typeof body.taxId !== 'number' || !Number.isInteger(body.taxId) || body.taxId <= 0)) {
|
|
return res.status(400).json({ error: 'Invalid taxId' });
|
|
}
|
|
patch.taxId = body.taxId;
|
|
}
|
|
if (body.taxRate !== undefined) {
|
|
if (body.taxRate !== null && (typeof body.taxRate !== 'number' || !Number.isFinite(body.taxRate) || body.taxRate < 0)) {
|
|
return res.status(400).json({ error: 'Invalid taxRate' });
|
|
}
|
|
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 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /article/{sku}/supplier/order:
|
|
* put:
|
|
* tags: [Article]
|
|
* summary: Reorder article suppliers
|
|
* description: |
|
|
* Sets the display sort order of supplier assignments for an article.
|
|
* Expects an array of all assigned `articlesupplier` IDs in the desired order.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-signals: [article-<sku>]
|
|
* x-markdown: article/supplier/order.md
|
|
* parameters:
|
|
* - in: path
|
|
* name: sku
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Article SKU
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [ids]
|
|
* properties:
|
|
* ids:
|
|
* type: array
|
|
* items:
|
|
* type: integer
|
|
* description: Full list of articlesupplier ids in their new order
|
|
* example:
|
|
* ids: [12, 10, 15]
|
|
* responses:
|
|
* 200:
|
|
* description: New order applied
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ids:
|
|
* type: array
|
|
* items:
|
|
* type: integer
|
|
* 400:
|
|
* description: Invalid supplier order, ids is required, 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 or company not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
app.put(`${basePath}/article/:sku/supplier/order`, 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 rawIds = req.body?.ids;
|
|
if (!Array.isArray(rawIds)) {
|
|
return res.status(400).json({ error: 'ids is required' });
|
|
}
|
|
|
|
const ids = [];
|
|
for (const value of rawIds) {
|
|
const id = Number(value);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'Invalid supplier order' });
|
|
}
|
|
ids.push(id);
|
|
}
|
|
|
|
try {
|
|
const result = await db.order(req.user.id, skuParsed.sku, { ids });
|
|
emitArticleSignal(req, skuParsed.sku);
|
|
res.status(200).json(result);
|
|
} catch (error) {
|
|
const status = articleSupplierErrorStatus(error);
|
|
if (status === 500) console.error('Order article suppliers failed:', error);
|
|
res.status(status).json({ error: error.message });
|
|
}
|
|
});
|
|
}
|