Add unit API and article unitamount/unitSi patch.

Units use the selected company; article PATCH accepts name/unitamount/unitSi.
This commit is contained in:
seb
2026-08-01 06:15:30 +02:00
parent aba9779161
commit bfb1d8762a
13 changed files with 744 additions and 29 deletions

View File

@@ -57,6 +57,22 @@
* type: integer
* name:
* type: string
* Unit:
* type: object
* required: [si, basesi, basesifactor]
* properties:
* si:
* type: string
* basesi:
* type: string
* basesifactor:
* type: number
* symbol:
* type: string
* nullable: true
* name:
* type: string
* nullable: true
* ArticleSummary:
* type: object
* properties:

View File

@@ -36,6 +36,8 @@ function articleErrorStatus(error) {
if (error.message === 'Article has children') return 400;
if (error.message === 'Invalid prices') return 400;
if (error.message === 'POS customer group required') return 400;
if (error.message === 'Invalid unitamount') return 400;
if (error.message === 'Unit not found') return 404;
return 500;
}
@@ -185,7 +187,9 @@ export function register(app, basePath = '') {
* patch:
* tags: [Article]
* summary: Update article by SKU
* description: Updates an article name by SKU; returns the same detail shape as GET.
* description: |
* Patches article fields. At least one of `name`, `unitamount`, `unitSi` required.
* `unitSi` must already exist for the company. Returns the same detail shape as GET.
* security:
* - cookieAuth: []
* x-markdown: article/update.md
@@ -202,12 +206,18 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* type: object
* required: [name]
* properties:
* name:
* type: string
* unitamount:
* type: number
* description: Must be finite and `> 0`
* unitSi:
* type: string
* description: Existing company unit `si`
* example:
* name: Updated name
* unitamount: 0.24
* unitSi: l
* responses:
* 200:
* description: Updated article detail
@@ -219,7 +229,7 @@ export function register(app, basePath = '') {
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Validation error
* description: Validation error (Name required / Invalid unitamount)
* content:
* application/json:
* schema:
@@ -237,7 +247,7 @@ export function register(app, basePath = '') {
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Article not found
* description: Article or unit not found
* content:
* application/json:
* schema:
@@ -309,13 +319,24 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'sku is required' });
}
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name) {
return res.status(400).json({ error: 'name is required' });
const body = req.body ?? {};
const patch = {};
if (Object.prototype.hasOwnProperty.call(body, 'name')) {
patch.name = body.name;
}
if (Object.prototype.hasOwnProperty.call(body, 'unitamount')) {
patch.unitamount = body.unitamount;
}
if (Object.prototype.hasOwnProperty.call(body, 'unitSi')) {
patch.unitSi = body.unitSi;
}
if (!('name' in patch) && !('unitamount' in patch) && !('unitSi' in patch)) {
return res.status(400).json({ error: 'name, unitamount, or unitSi is required' });
}
try {
const article = await db.updateBySku(req.user.id, decodeURIComponent(sku), { name });
const article = await db.updateBySku(req.user.id, decodeURIComponent(sku), patch);
res.status(200).json({ article });
} catch (error) {
const status = articleErrorStatus(error);

246
src/api/routes/unit.js Normal file
View File

@@ -0,0 +1,246 @@
import * as db from '../../db/unit.js';
function requireSelectedCompany(req) {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
function unitErrorStatus(error) {
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found') return 404;
if (error.message === 'Unit not found') return 404;
if (error.message === 'Invalid unit') return 400;
if (error.message === 'Unit in use') return 409;
return 500;
}
/**
* Registers unit routes for the authenticated user's selected company.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /unit:
* get:
* tags: [Unit]
* summary: List units
* description: Lists units for the authenticated user's selected company (default-language symbol/name).
* security:
* - cookieAuth: []
* x-markdown: unit/list.md
* responses:
* 200:
* description: Unit list
* content:
* application/json:
* schema:
* type: object
* properties:
* units:
* type: array
* items:
* $ref: '#/components/schemas/Unit'
* 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'
* put:
* tags: [Unit]
* summary: Upsert unit
* description: |
* Insert or update unit by `si` for the selected company. Upserts `unittext` for the company language.
* `basesifactor` must be finite and `> 0`.
* security:
* - cookieAuth: []
* x-markdown: unit/upsert.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [si, basesi, basesifactor, symbol, name]
* properties:
* si:
* type: string
* basesi:
* type: string
* basesifactor:
* type: number
* symbol:
* type: string
* name:
* type: string
* example:
* si: l
* basesi: l
* basesifactor: 1
* symbol: L
* name: Liter
* responses:
* 200:
* description: Upserted unit
* content:
* application/json:
* schema:
* type: object
* properties:
* unit:
* $ref: '#/components/schemas/Unit'
* 400:
* description: Invalid unit 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 not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* delete:
* tags: [Unit]
* summary: Delete unit
* description: Deletes `si` for the selected company. Fails with `Unit in use` if articles still reference it.
* security:
* - cookieAuth: []
* x-markdown: unit/delete.md
* parameters:
* - in: query
* name: si
* required: true
* schema:
* type: string
* responses:
* 204:
* description: Deleted
* 400:
* description: Missing si 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 or unit not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Unit in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/unit`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
try {
const units = await db.list(req.user.id, selected.companyId);
res.status(200).json({ units });
} catch (error) {
const status = unitErrorStatus(error);
if (status === 500) console.error('List units failed:', error);
res.status(status).json({ error: error.message });
}
});
app.put(`${basePath}/unit`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const { si, basesi, basesifactor, symbol, name } = req.body ?? {};
try {
const unit = await db.upsert(req.user.id, selected.companyId, {
si,
basesi,
basesifactor,
symbol,
name,
});
res.status(200).json({ unit });
} catch (error) {
const status = unitErrorStatus(error);
if (status === 500) console.error('Upsert unit failed:', error);
res.status(status).json({ error: error.message });
}
});
app.delete(`${basePath}/unit`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const si = typeof req.query.si === 'string' ? req.query.si.trim() : '';
if (!si) {
return res.status(400).json({ error: 'si is required' });
}
try {
await db.remove(req.user.id, selected.companyId, si);
res.sendStatus(204);
} catch (error) {
const status = unitErrorStatus(error);
if (status === 500) console.error('Delete unit failed:', error);
res.status(status).json({ error: error.message });
}
});
}

View File

@@ -13,6 +13,7 @@ const STORE_NAMESPACES = [
'category',
'article',
'pic',
'unit',
'lang',
'tax',
'google_auth',

16
src/db/unit.js Normal file
View File

@@ -0,0 +1,16 @@
import { getStore } from './store.js';
export async function list(googleAuthId, companyId) {
const store = await getStore();
return store.unit.list(googleAuthId, companyId);
}
export async function upsert(googleAuthId, companyId, data) {
const store = await getStore();
return store.unit.upsert(googleAuthId, companyId, data);
}
export async function remove(googleAuthId, companyId, si) {
const store = await getStore();
return store.unit.delete(googleAuthId, companyId, si);
}