Compare commits
3 Commits
ed91ccbaee
...
74ed74fc68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ed74fc68 | ||
|
|
3d2f8e9296 | ||
|
|
bc467e29da |
@@ -234,7 +234,7 @@ type Entity = {
|
||||
}
|
||||
```
|
||||
|
||||
`list` optional filters: `customergroupId` (must belong to the company), or `role: 'customer'` (entities whose customergroup name is not `Lieferanten` and not `POS` — for quote/order pickers). `role` wins over `customergroupId` when both are set. Unknown `role` → `Invalid role`.
|
||||
`list` optional filters: `customergroupId` (must belong to the company), or `role: 'customer'` (entities whose customergroup name is not `Lieferanten` — for quote/order pickers). `role` wins over `customergroupId` when both are set. Unknown `role` → `Invalid role`.
|
||||
|
||||
`number` is optional on the row (e.g. suppliers leave it null). Non-null numbers are unique per company (via `customergroup`). `create` allocates via `getnextnumberwithprefixandpadding` (`type` `entity`) when `number` is omitted. `update` only changes `number` when the key is present (no auto-allocate). `peekNextNumber` returns what the next allocate would yield without consuming it (`peeknextnumberwithprefixandpadding`; ensures the `numbers` row exists). `isNumberFree` checks occupancy company-wide; use `excludeEntityId` on edit so the current entity’s number stays free. Empty `number` → `Number required`.
|
||||
|
||||
@@ -397,7 +397,7 @@ type ManifestLine = {
|
||||
type Quote = QuoteSummary & { lines: ManifestLine[] }
|
||||
```
|
||||
|
||||
`entityId` null or a company entity whose customergroup is not named `Lieferanten` or `POS` (else `Entity not found`). Lines: `sku` (category tree → `Article not found`), `amount`, `net`, `tax` (`tax.ensure`); `rebate` defaults `0`. Empty/invalid `number` or `date` → `Invalid quote`; bad lines → `Invalid lines`.
|
||||
`entityId` null or a company entity whose customergroup is not named `Lieferanten` (else `Entity not found`). Lines: `sku` (category tree → `Article not found`), `amount`, `net`, `tax` (`tax.ensure`); `rebate` defaults `0`. Empty/invalid `number` or `date` → `Invalid quote`; bad lines → `Invalid lines`.
|
||||
|
||||
`update`: present fields only; `lines` present → replace-all. `delete` cascades lines. Errors: `No company selected`, `Company not found`, `Not authorized`, `Quote not found`, `Entity not found`, `Article not found`, `Invalid quote`, `Invalid lines`.
|
||||
|
||||
@@ -405,7 +405,7 @@ type Quote = QuoteSummary & { lines: ManifestLine[] }
|
||||
|
||||
## `order`
|
||||
|
||||
Company-scoped orders on `manifest` / `manifestarticle` with fixed `type = 'order'`. Same API shape as `quote` (`entityId` customer, not Lieferanten/POS). `manifest.date` may be documented as expected delivery in HTTP docs.
|
||||
Company-scoped orders on `manifest` / `manifestarticle` with fixed `type = 'order'`. Same API shape as `quote` (`entityId` customer, not Lieferanten). `manifest.date` may be documented as expected delivery in HTTP docs.
|
||||
|
||||
| Method | Signature | Returns |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -140,8 +140,10 @@ SELECT
|
||||
av.value AS attributeValue
|
||||
FROM article child
|
||||
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = @languageId
|
||||
LEFT JOIN articleattributevalue aav ON aav.article_id = child.id
|
||||
LEFT JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = @attributeId
|
||||
LEFT JOIN (
|
||||
articleattributevalue aav
|
||||
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = @attributeId
|
||||
) ON aav.article_id = child.id
|
||||
WHERE child.fatherarticle_id = @fatherArticleId
|
||||
ORDER BY child.fathersort ASC, child.id ASC
|
||||
`;
|
||||
@@ -499,8 +501,10 @@ SELECT
|
||||
av.value AS attributeValue
|
||||
FROM article child
|
||||
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = auth.languageId
|
||||
LEFT JOIN articleattributevalue aav ON aav.article_id = child.id
|
||||
LEFT JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.fatherattributename_id
|
||||
LEFT JOIN (
|
||||
articleattributevalue aav
|
||||
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.fatherattributename_id
|
||||
) ON aav.article_id = child.id
|
||||
WHERE child.fatherarticle_id = a.id
|
||||
ORDER BY child.fathersort ASC, child.id ASC
|
||||
FOR JSON PATH
|
||||
|
||||
@@ -4,6 +4,12 @@ FROM article
|
||||
WHERE fatherarticle_id = @articleId
|
||||
`;
|
||||
|
||||
export const GET_CHILDREN_IDS = `
|
||||
SELECT id
|
||||
FROM article
|
||||
WHERE fatherarticle_id = @id
|
||||
`;
|
||||
|
||||
export const GET_LIST_IDS = `
|
||||
SELECT articlelist_id AS articlelistId, optionslist_id AS optionslistId
|
||||
FROM article
|
||||
|
||||
@@ -30,14 +30,14 @@ WHERE cg.company_id = @companyId
|
||||
ORDER BY e.name, e.id
|
||||
`;
|
||||
|
||||
/** Entities whose group is not Lieferanten and not POS (customer picker). */
|
||||
/** Entities whose group is not Lieferanten (customer picker). */
|
||||
export const LIST_CUSTOMERS_BY_COMPANY = `
|
||||
SELECT
|
||||
${ENTITY_COLUMNS}
|
||||
FROM entity e
|
||||
INNER JOIN customergroup cg ON cg.id = e.customergroup_id
|
||||
WHERE cg.company_id = @companyId
|
||||
AND cg.name NOT IN (@supplierGroupName, @posGroupName)
|
||||
AND cg.name <> @supplierGroupName
|
||||
ORDER BY e.name, e.id
|
||||
`;
|
||||
|
||||
@@ -60,7 +60,7 @@ WHERE cg.company_id = @companyId
|
||||
AND (@excludeEntityId IS NULL OR e.id <> @excludeEntityId)
|
||||
`;
|
||||
|
||||
/** Customer entity: company-scoped and not Lieferanten/POS. */
|
||||
/** Customer entity: company-scoped and not Lieferanten. */
|
||||
export const GET_CUSTOMER_BY_ID = `
|
||||
SELECT
|
||||
${ENTITY_COLUMNS}
|
||||
@@ -68,7 +68,7 @@ FROM entity e
|
||||
INNER JOIN customergroup cg ON cg.id = e.customergroup_id
|
||||
WHERE e.id = @id
|
||||
AND cg.company_id = @companyId
|
||||
AND cg.name NOT IN (@supplierGroupName, @posGroupName)
|
||||
AND cg.name <> @supplierGroupName
|
||||
`;
|
||||
|
||||
export const LIST_SUPPLIERS_BY_COMPANY = `
|
||||
|
||||
@@ -175,8 +175,10 @@ SELECT auth.*,
|
||||
'attributeValue', av.value
|
||||
)), '[]') FROM article child
|
||||
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = auth.languageId
|
||||
LEFT JOIN articleattributevalue aav ON aav.article_id = child.id
|
||||
LEFT JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.fatherattributename_id
|
||||
LEFT JOIN (
|
||||
articleattributevalue aav
|
||||
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.fatherattributename_id
|
||||
) ON aav.article_id = child.id
|
||||
WHERE child.fatherarticle_id = a.id
|
||||
ORDER BY child.fathersort ASC, child.id ASC)),
|
||||
'categoryIdsJson', json(${JSON_CATEGORIES}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { translateQueries } from './translate.js';
|
||||
const queries = translateQueries(source, 'articleDelete');
|
||||
export const {
|
||||
DELETE_ARTICLELIST, DELETE_BY_ID, DELETE_OPTIONSLIST, GET_CHILD_COUNT,
|
||||
GET_LIST_IDS,
|
||||
GET_CHILDREN_IDS, GET_LIST_IDS,
|
||||
} = queries;
|
||||
|
||||
export const DELETE_PICS_BY_ARTICLE = `
|
||||
|
||||
@@ -78,8 +78,11 @@ export default function addChild(provider) {
|
||||
let finalValue = null;
|
||||
|
||||
if (options.attributeValueId != null) {
|
||||
if (typeof options.attributeValueId !== 'number' || !Number.isInteger(options.attributeValueId) || options.attributeValueId <= 0) {
|
||||
throw new Error('Invalid attributeValueId');
|
||||
}
|
||||
const valRes = await runner.query(attrValQueries.GET_BY_ID_AND_COMPANY, {
|
||||
id: Number(options.attributeValueId),
|
||||
id: options.attributeValueId,
|
||||
companyId,
|
||||
});
|
||||
const row = valRes.rows[0];
|
||||
|
||||
@@ -9,43 +9,71 @@ export default function del(provider) {
|
||||
const queries = isSqlite ? sqliteQueries : mssqlQueries;
|
||||
|
||||
/**
|
||||
* Deletes an article by its SKU, refusing when it still has children.
|
||||
* Deletes an article by its SKU, refusing when it still has children unless cascade is true.
|
||||
*
|
||||
* @param {number} googleAuthId - The id of the Google auth user.
|
||||
* @param {string} sku - The article SKU.
|
||||
* @param {object} [tx] - Optional transaction to run within.
|
||||
* @param {object|*} [optionsOrTx] - Options object ({ cascade?: boolean }) or optional transaction.
|
||||
* @param {object} [maybeTx] - Optional transaction if options was passed.
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If the article has children or is not found.
|
||||
* @throws {Error} If the article has children (when cascade=false) or is not found.
|
||||
*
|
||||
* @example
|
||||
* await store.article.delete(42, 'SKU-1');
|
||||
* await store.article.delete(42, 'SKU-1', { cascade: true });
|
||||
*/
|
||||
return async (googleAuthId, sku, tx) => {
|
||||
return async (googleAuthId, sku, optionsOrTx, maybeTx) => {
|
||||
let options = {};
|
||||
let tx;
|
||||
if (optionsOrTx && typeof optionsOrTx === 'object' && typeof optionsOrTx.query !== 'function') {
|
||||
options = optionsOrTx;
|
||||
tx = maybeTx;
|
||||
} else {
|
||||
tx = optionsOrTx;
|
||||
}
|
||||
const cascade = Boolean(options.cascade);
|
||||
|
||||
const runDelete = async (runner) => {
|
||||
const { articleId } = await resolve(googleAuthId, sku, runner);
|
||||
|
||||
const deleteArticleAndLists = async (id) => {
|
||||
const listIds = await runner.query(queries.GET_LIST_IDS, { articleId: id });
|
||||
const { articlelistId, optionslistId } = listIds.rows[0] ?? {};
|
||||
|
||||
await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId: id });
|
||||
|
||||
const deleted = await runner.query(queries.DELETE_BY_ID, { articleId: id });
|
||||
if (!deleted.rowsAffected) {
|
||||
throw new Error('Article not found');
|
||||
}
|
||||
|
||||
if (articlelistId != null) {
|
||||
await runner.query(queries.DELETE_ARTICLELIST, { id: articlelistId });
|
||||
}
|
||||
|
||||
if (optionslistId != null) {
|
||||
await runner.query(queries.DELETE_OPTIONSLIST, { id: optionslistId });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSubtree = async (id) => {
|
||||
const children = await runner.query(queries.GET_CHILDREN_IDS, { id });
|
||||
for (const child of children.rows) {
|
||||
await deleteSubtree(child.id);
|
||||
}
|
||||
await deleteArticleAndLists(id);
|
||||
};
|
||||
|
||||
const childCount = await runner.query(queries.GET_CHILD_COUNT, { articleId });
|
||||
if (childCount.rows[0].count > 0) {
|
||||
throw new Error('Article has children');
|
||||
if (!cascade) {
|
||||
throw new Error('Article has children');
|
||||
}
|
||||
await deleteSubtree(articleId);
|
||||
return;
|
||||
}
|
||||
|
||||
const listIds = await runner.query(queries.GET_LIST_IDS, { articleId });
|
||||
const { articlelistId, optionslistId } = listIds.rows[0] ?? {};
|
||||
|
||||
await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId });
|
||||
|
||||
const deleted = await runner.query(queries.DELETE_BY_ID, { articleId });
|
||||
if (!deleted.rowsAffected) {
|
||||
throw new Error('Article not found');
|
||||
}
|
||||
|
||||
if (articlelistId != null) {
|
||||
await runner.query(queries.DELETE_ARTICLELIST, { id: articlelistId });
|
||||
}
|
||||
|
||||
if (optionslistId != null) {
|
||||
await runner.query(queries.DELETE_OPTIONSLIST, { id: optionslistId });
|
||||
}
|
||||
await deleteArticleAndLists(articleId);
|
||||
};
|
||||
|
||||
if (tx) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function removeChild(provider) {
|
||||
* @param {string} childSku - The child article SKU.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.deleteArticle=false] - Whether to delete the child article entity entirely.
|
||||
* @param {boolean} [options.cascade=true] - Whether to cascade delete child subtrees when deleteArticle is true.
|
||||
* @param {object} [tx] - Optional transaction.
|
||||
* @returns {Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>}
|
||||
*/
|
||||
@@ -45,7 +46,8 @@ export default function removeChild(provider) {
|
||||
}
|
||||
|
||||
if (options.deleteArticle) {
|
||||
await del(googleAuthId, childSku, runner);
|
||||
const cascade = options.cascade !== undefined ? Boolean(options.cascade) : true;
|
||||
await del(googleAuthId, childSku, { cascade }, runner);
|
||||
return {
|
||||
success: true,
|
||||
fatherSku,
|
||||
|
||||
@@ -40,8 +40,8 @@ export default function setBrandIdBySku(provider) {
|
||||
runner,
|
||||
);
|
||||
|
||||
if (brandId !== null && !Number.isFinite(brandId)) {
|
||||
throw new Error('Brand not found');
|
||||
if (brandId !== null && (typeof brandId !== 'number' || !Number.isInteger(brandId) || brandId <= 0)) {
|
||||
throw new Error('Invalid brandId');
|
||||
}
|
||||
|
||||
let resolvedBrandId = null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ADD_EAN as SQLITE_ADD_EAN,
|
||||
DELETE_EANS_BY_ARTICLE as SQLITE_DELETE_EANS,
|
||||
} from '../../providers/sqlite3/queries/article.js';
|
||||
import { isUniqueConstraintError } from '../../providers/provider.js';
|
||||
import queryBySku from './queryBySku.js';
|
||||
import resolveArticleBySku from './resolveArticleBySku.js';
|
||||
import makeRunner from '../runner.js';
|
||||
@@ -55,8 +56,15 @@ export default function setEansBySku(provider) {
|
||||
|
||||
await runner.query(deleteEansSql, { articleId });
|
||||
|
||||
for (const ean of normalized) {
|
||||
await runner.query(addEanSql, { articleId, ean });
|
||||
try {
|
||||
for (const ean of normalized) {
|
||||
await runner.query(addEanSql, { articleId, ean });
|
||||
}
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintError(err)) {
|
||||
throw new Error('EAN already in use');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const article = await load(articleId, languageId, runner);
|
||||
|
||||
@@ -27,10 +27,11 @@ function normalizePrices(prices) {
|
||||
? 1
|
||||
: entry.minmultiplier;
|
||||
|
||||
if (!Number.isFinite(price) || !Number.isFinite(tax) || !Number.isFinite(minmultiplier)) {
|
||||
throw new Error('Invalid prices');
|
||||
}
|
||||
if (minmultiplier <= 0) {
|
||||
if (
|
||||
typeof price !== 'number' || !Number.isFinite(price) || price < 0 ||
|
||||
typeof tax !== 'number' || !Number.isFinite(tax) || tax < 0 ||
|
||||
typeof minmultiplier !== 'number' || !Number.isFinite(minmultiplier) || minmultiplier <= 0
|
||||
) {
|
||||
throw new Error('Invalid prices');
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,16 @@ export default function toggleFather(provider) {
|
||||
}
|
||||
|
||||
const stockRes = await runner.query(
|
||||
'SELECT COALESCE(SUM(l.amount), 0) AS totalStock FROM ledger l WHERE l.article_id = @articleId',
|
||||
{ articleId },
|
||||
`SELECT COALESCE(SUM(l.amount), 0) AS totalStock
|
||||
FROM ledger l
|
||||
INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id
|
||||
INNER JOIN manifest m ON m.id = ma.manifest_id
|
||||
WHERE m.company_id = @companyId
|
||||
AND (
|
||||
ma.sku = @sku
|
||||
OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId)
|
||||
)`,
|
||||
{ companyId, sku, articleId },
|
||||
);
|
||||
const totalStock = Number(stockRes.rows[0]?.totalStock ?? 0);
|
||||
if (totalStock !== 0) {
|
||||
|
||||
@@ -4,6 +4,39 @@ import resolveArticleBySku from '../article/resolveArticleBySku.js';
|
||||
import getItemsByArticleSku from './getItemsByArticleSku.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
async function hasArticlelistCycle(runner, rootArticleId, candidateComponentId) {
|
||||
const visited = new Set();
|
||||
const queue = [candidateComponentId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift();
|
||||
if (currentId === rootArticleId) {
|
||||
return true;
|
||||
}
|
||||
if (visited.has(currentId)) continue;
|
||||
visited.add(currentId);
|
||||
|
||||
const res = await runner.query(
|
||||
`SELECT ala.article_id
|
||||
FROM article a
|
||||
INNER JOIN articlelistarticle ala ON ala.articlelist_id = a.articlelist_id
|
||||
WHERE a.id = @currentId`,
|
||||
{ currentId },
|
||||
);
|
||||
|
||||
for (const row of res.rows) {
|
||||
if (row.article_id === rootArticleId) {
|
||||
return true;
|
||||
}
|
||||
if (!visited.has(row.article_id)) {
|
||||
queue.push(row.article_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function setItemsByArticleSku(provider) {
|
||||
const resolve = resolveArticleBySku(provider);
|
||||
const getItems = getItemsByArticleSku(provider);
|
||||
@@ -75,8 +108,8 @@ export default function setItemsByArticleSku(provider) {
|
||||
throw new Error(`Item at index ${i} must specify a valid articleId or sku`);
|
||||
}
|
||||
|
||||
if (itemArticleId === articleId) {
|
||||
throw new Error('A Stückliste article cannot contain itself');
|
||||
if (itemArticleId === articleId || await hasArticlelistCycle(runner, articleId, itemArticleId)) {
|
||||
throw new Error('A Stückliste article cannot contain itself or form a circular reference');
|
||||
}
|
||||
|
||||
if (seenArticleIds.has(itemArticleId)) {
|
||||
@@ -93,7 +126,16 @@ export default function setItemsByArticleSku(provider) {
|
||||
throw new Error(`Article not found for component: ${itemArticleId}`);
|
||||
}
|
||||
|
||||
const count = item?.count != null && Number(item.count) > 0 ? Number(item.count) : 1;
|
||||
const rawCount = item?.count;
|
||||
if (
|
||||
typeof rawCount !== 'number' ||
|
||||
!Number.isInteger(rawCount) ||
|
||||
rawCount <= 0 ||
|
||||
rawCount > 2147483647
|
||||
) {
|
||||
throw new Error(`Invalid count for Stückliste item at index ${i}: must be a positive integer`);
|
||||
}
|
||||
const count = rawCount;
|
||||
let sort = item?.sort != null && Number.isInteger(Number(item.sort)) ? Number(item.sort) : i;
|
||||
|
||||
while (seenSorts.has(sort)) {
|
||||
|
||||
@@ -53,8 +53,11 @@ export async function assertSupplier(runnerOrPool, txOrCompanyId, maybeCompanyId
|
||||
supplierId = maybeCompanyId;
|
||||
}
|
||||
|
||||
if (supplierId == null || !Number.isFinite(supplierId)) {
|
||||
throw new Error('Supplier not found');
|
||||
if (supplierId == null) {
|
||||
throw new Error('supplierId is required');
|
||||
}
|
||||
if (typeof supplierId !== 'number' || !Number.isInteger(supplierId) || supplierId <= 0) {
|
||||
throw new Error('Invalid supplierId');
|
||||
}
|
||||
|
||||
const isSqlite = runner?.dialect === 'sqlite3';
|
||||
|
||||
@@ -10,8 +10,8 @@ export default function resolveCustomerGroupAccess(provider) {
|
||||
: MSSQL_GET_BY_COMPANY_AND_ID;
|
||||
|
||||
return async (companyId, customergroupId, tx) => {
|
||||
if (!Number.isFinite(customergroupId)) {
|
||||
throw new Error('Customer group not found');
|
||||
if (typeof customergroupId !== 'number' || !Number.isInteger(customergroupId) || customergroupId <= 0) {
|
||||
throw new Error('Invalid customergroupId');
|
||||
}
|
||||
|
||||
const result = await makeRunner(provider, tx).query(sql, {
|
||||
|
||||
@@ -6,8 +6,8 @@ export async function assertSupplier(provider, tx, companyId, supplierId) {
|
||||
if (supplierId == null) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(supplierId)) {
|
||||
throw new Error('Supplier not found');
|
||||
if (typeof supplierId !== 'number' || !Number.isInteger(supplierId) || supplierId <= 0) {
|
||||
throw new Error('Invalid supplierId');
|
||||
}
|
||||
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
|
||||
LIST_BY_COMPANY_AND_GROUP as MSSQL_LIST_BY_COMPANY_AND_GROUP,
|
||||
LIST_CUSTOMERS_BY_COMPANY as MSSQL_LIST_CUSTOMERS_BY_COMPANY,
|
||||
POS_GROUP_NAME,
|
||||
SUPPLIER_GROUP_NAME,
|
||||
} from '../../providers/mssql/queries/entity.js';
|
||||
import {
|
||||
@@ -58,7 +57,6 @@ export default function list(provider) {
|
||||
const result = await runner.query(listCustomersSql, {
|
||||
companyId,
|
||||
supplierGroupName: SUPPLIER_GROUP_NAME,
|
||||
posGroupName: POS_GROUP_NAME,
|
||||
});
|
||||
return result.rows.map(shape);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
} from '../../providers/sqlite3/queries/manifestDoc.js';
|
||||
import {
|
||||
GET_CUSTOMER_BY_ID as MSSQL_GET_CUSTOMER_BY_ID,
|
||||
POS_GROUP_NAME as MSSQL_POS_GROUP_NAME,
|
||||
SUPPLIER_GROUP_NAME as MSSQL_SUPPLIER_GROUP_NAME,
|
||||
} from '../../providers/mssql/queries/entity.js';
|
||||
import {
|
||||
GET_CUSTOMER_BY_ID as SQLITE_GET_CUSTOMER_BY_ID,
|
||||
POS_GROUP_NAME as SQLITE_POS_GROUP_NAME,
|
||||
SUPPLIER_GROUP_NAME as SQLITE_SUPPLIER_GROUP_NAME,
|
||||
} from '../../providers/sqlite3/queries/entity.js';
|
||||
import makeRunner from '../runner.js';
|
||||
@@ -49,20 +47,18 @@ export async function assertCustomerEntity(runnerOrPool, txOrCompanyId, maybeCom
|
||||
if (entityId == null) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(entityId)) {
|
||||
throw new Error('Entity not found');
|
||||
if (typeof entityId !== 'number' || !Number.isInteger(entityId) || entityId <= 0) {
|
||||
throw new Error('Invalid entityId');
|
||||
}
|
||||
|
||||
const isSqlite = runner?.dialect === 'sqlite3';
|
||||
const getCustomerSql = isSqlite ? SQLITE_GET_CUSTOMER_BY_ID : MSSQL_GET_CUSTOMER_BY_ID;
|
||||
const supplierGroupName = isSqlite ? SQLITE_SUPPLIER_GROUP_NAME : MSSQL_SUPPLIER_GROUP_NAME;
|
||||
const posGroupName = isSqlite ? SQLITE_POS_GROUP_NAME : MSSQL_POS_GROUP_NAME;
|
||||
|
||||
const result = await runner.query(getCustomerSql, {
|
||||
id: entityId,
|
||||
companyId,
|
||||
supplierGroupName,
|
||||
posGroupName,
|
||||
});
|
||||
|
||||
if (!result.rows[0]) {
|
||||
|
||||
@@ -36,11 +36,11 @@ export function normalizeLines(lines) {
|
||||
: String(entry.comment);
|
||||
|
||||
if (
|
||||
!Number.isFinite(pu) ||
|
||||
!Number.isFinite(amount) ||
|
||||
!Number.isFinite(net) ||
|
||||
!Number.isFinite(rebate) ||
|
||||
!Number.isFinite(taxrate)
|
||||
typeof pu !== 'number' || !Number.isFinite(pu) || pu <= 0 ||
|
||||
typeof amount !== 'number' || !Number.isFinite(amount) ||
|
||||
typeof net !== 'number' || !Number.isFinite(net) ||
|
||||
typeof rebate !== 'number' || !Number.isFinite(rebate) ||
|
||||
typeof taxrate !== 'number' || !Number.isFinite(taxrate) || taxrate < 0
|
||||
) {
|
||||
throw new Error('Invalid lines');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,39 @@ import resolveArticleBySku from '../article/resolveArticleBySku.js';
|
||||
import getOptionsByArticleSku from './getOptionsByArticleSku.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
async function hasOptionslistCycle(runner, rootArticleId, candidateOptionId) {
|
||||
const visited = new Set();
|
||||
const queue = [candidateOptionId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift();
|
||||
if (currentId === rootArticleId) {
|
||||
return true;
|
||||
}
|
||||
if (visited.has(currentId)) continue;
|
||||
visited.add(currentId);
|
||||
|
||||
const res = await runner.query(
|
||||
`SELECT ola.article_id
|
||||
FROM article a
|
||||
INNER JOIN optionslistarticle ola ON ola.optionslist_id = a.optionslist_id
|
||||
WHERE a.id = @currentId`,
|
||||
{ currentId },
|
||||
);
|
||||
|
||||
for (const row of res.rows) {
|
||||
if (row.article_id === rootArticleId) {
|
||||
return true;
|
||||
}
|
||||
if (!visited.has(row.article_id)) {
|
||||
queue.push(row.article_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function setOptionsByArticleSku(provider) {
|
||||
const resolve = resolveArticleBySku(provider);
|
||||
const getOptions = getOptionsByArticleSku(provider);
|
||||
@@ -75,8 +108,8 @@ export default function setOptionsByArticleSku(provider) {
|
||||
throw new Error(`Option at index ${i} must specify a valid articleId or sku`);
|
||||
}
|
||||
|
||||
if (itemArticleId === articleId) {
|
||||
throw new Error('An article cannot have itself as an option');
|
||||
if (itemArticleId === articleId || await hasOptionslistCycle(runner, articleId, itemArticleId)) {
|
||||
throw new Error('An article cannot have itself as an option or form a circular reference in options');
|
||||
}
|
||||
|
||||
if (seenArticleIds.has(itemArticleId)) {
|
||||
@@ -93,7 +126,16 @@ export default function setOptionsByArticleSku(provider) {
|
||||
throw new Error(`Article not found for option: ${itemArticleId}`);
|
||||
}
|
||||
|
||||
const count = item?.count != null && Number(item.count) > 0 ? Number(item.count) : 1;
|
||||
const rawCount = item?.count;
|
||||
if (
|
||||
typeof rawCount !== 'number' ||
|
||||
!Number.isInteger(rawCount) ||
|
||||
rawCount <= 0 ||
|
||||
rawCount > 2147483647
|
||||
) {
|
||||
throw new Error(`Invalid count for options item at index ${i}: must be a positive integer`);
|
||||
}
|
||||
const count = rawCount;
|
||||
let sort = item?.sort != null && Number.isInteger(Number(item.sort)) ? Number(item.sort) : i;
|
||||
|
||||
while (seenSorts.has(sort)) {
|
||||
|
||||
@@ -82,18 +82,18 @@ export default function adjust(provider) {
|
||||
if (rawWhId == null) {
|
||||
throw new Error(`Warehouse required${itemLabel} (${sku})`);
|
||||
}
|
||||
const warehouseId = Number(rawWhId);
|
||||
if (!Number.isInteger(warehouseId) || warehouseId <= 0) {
|
||||
if (typeof rawWhId !== 'number' || !Number.isInteger(rawWhId) || rawWhId <= 0) {
|
||||
throw new Error(`Warehouse must be a positive integer${itemLabel} (${sku})`);
|
||||
}
|
||||
const warehouseId = rawWhId;
|
||||
|
||||
if (line.amount == null) {
|
||||
throw new Error(`Amount required${itemLabel} (${sku})`);
|
||||
}
|
||||
const amount = Number(line.amount);
|
||||
if (!Number.isFinite(amount) || amount === 0) {
|
||||
if (typeof line.amount !== 'number' || !Number.isFinite(line.amount) || line.amount === 0) {
|
||||
throw new Error(`Invalid amount${itemLabel} (${sku}): must be non-zero number`);
|
||||
}
|
||||
const amount = line.amount;
|
||||
|
||||
const text = (line.text ?? line.transactionText ?? line.transaction_text ?? defaultText)?.trim?.() ||
|
||||
(amount > 0 ? `Manual stock addition (+${amount})` : `Manual stock removal (${amount})`);
|
||||
|
||||
@@ -16,6 +16,9 @@ export default function create(provider) {
|
||||
* // => 1
|
||||
*/
|
||||
return async (rate, tx) => {
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0) {
|
||||
throw new Error('Invalid tax rate');
|
||||
}
|
||||
const result = await makeRunner(provider, tx).query(sql, { rate });
|
||||
return result.rows[0]?.id ?? result.insertId;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ export default function ensure(provider) {
|
||||
* // => 1
|
||||
*/
|
||||
return async (rate, tx) => {
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0) {
|
||||
throw new Error('Invalid tax rate');
|
||||
}
|
||||
const row = await get(rate, tx);
|
||||
if (row) return row.id;
|
||||
return add(rate, tx);
|
||||
|
||||
@@ -16,6 +16,9 @@ export default function getByRate(provider) {
|
||||
* // => { id: 1, rate: 19 }
|
||||
*/
|
||||
return async (rate, tx) => {
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0) {
|
||||
throw new Error('Invalid tax rate');
|
||||
}
|
||||
const result = await makeRunner(provider, tx).query(sql, { rate });
|
||||
return result.rows[0];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user