Files
dashlibstorage/src/store/article/setCategoryIdsBySku.js
2026-10-22 23:33:00 +02:00

172 lines
6.2 KiB
JavaScript

import {
ADD_CATEGORY as MSSQL_ADD_CATEGORY,
CLEAR_POSTERMINAL_OBITUARY as MSSQL_CLEAR_OBITUARY,
DELETE_CATEGORIES_BY_ARTICLE as MSSQL_DELETE_CATEGORIES,
INSERT_POSTERMINAL_OBITUARY as MSSQL_INSERT_OBITUARY,
LIST_PAIRED_POSTERMINAL_IDS_BY_ARTICLE as MSSQL_LIST_TERMINALS,
TOUCH_UPDATED_AT as MSSQL_TOUCH,
} from '../../providers/mssql/queries/article.js';
import {
ADD_CATEGORY as SQLITE_ADD_CATEGORY,
CLEAR_POSTERMINAL_OBITUARY as SQLITE_CLEAR_OBITUARY,
DELETE_CATEGORIES_BY_ARTICLE as SQLITE_DELETE_CATEGORIES,
INSERT_POSTERMINAL_OBITUARY as SQLITE_INSERT_OBITUARY,
LIST_PAIRED_POSTERMINAL_IDS_BY_ARTICLE as SQLITE_LIST_TERMINALS,
TOUCH_UPDATED_AT as SQLITE_TOUCH,
} from '../../providers/sqlite3/queries/article.js';
import resolveCategoryAccess from '../category/resolveCategoryAccess.js';
import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import { recordCategoryChangeHistory } from '../articlehistory/record.js';
import makeRunner from '../runner.js';
export default function setCategoryIdsBySku(provider) {
const resolve = resolveArticleBySku(provider);
const categoryAccess = resolveCategoryAccess(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const addCategorySql = isSqlite ? SQLITE_ADD_CATEGORY : MSSQL_ADD_CATEGORY;
const deleteCategoriesSql = isSqlite ? SQLITE_DELETE_CATEGORIES : MSSQL_DELETE_CATEGORIES;
const touchArticleSql = isSqlite ? SQLITE_TOUCH : MSSQL_TOUCH;
const listTerminalsSql = isSqlite ? SQLITE_LIST_TERMINALS : MSSQL_LIST_TERMINALS;
const insertObituarySql = isSqlite ? SQLITE_INSERT_OBITUARY : MSSQL_INSERT_OBITUARY;
const clearObituarySql = isSqlite ? SQLITE_CLEAR_OBITUARY : MSSQL_CLEAR_OBITUARY;
/**
* Replaces the category membership of an article by its SKU.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {Array<number>} categoryIds - The category ids the article should belong to (at least one).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<object>} The updated article.
* @throws {Error} If the category ids are invalid or the article is not found.
*
* @example
* const article = await store.article.setCategoryIdsBySku(42, 'SKU-1', [5, 9]);
* // => { id, sku, name, ... }
*/
return async (googleAuthId, sku, categoryIds, tx) => {
if (!Array.isArray(categoryIds)) {
throw new Error('Invalid categoryIds');
}
const seen = new Set();
const normalized = [];
for (const categoryId of categoryIds) {
if (!Number.isFinite(categoryId) || seen.has(categoryId)) {
throw new Error('Invalid categoryIds');
}
seen.add(categoryId);
normalized.push(categoryId);
}
if (normalized.length === 0) {
throw new Error('Article must have at least one category');
}
const runSet = async (runner) => {
const { articleId, languageId, companyId, companyuserId } = await resolve(
googleAuthId,
sku,
runner,
);
// Read old category IDs for history
const oldCatRes = await runner.query(
'SELECT category_id FROM categoryarticle WHERE article_id = @articleId ORDER BY category_id',
{ articleId },
);
const oldCategoryIds = oldCatRes.rows.map((r) => r.category_id);
for (const categoryId of normalized) {
// Ensures the category belongs to the same company as the article.
const access = await categoryAccess(googleAuthId, categoryId, runner);
if (access.companyId !== companyId) {
throw new Error('Invalid categoryIds');
}
}
// Find paired POS terminals where this article was visible before the category change
const terminalsBeforeRes = await runner.query(listTerminalsSql, {
articleId,
companyId,
});
const terminalsBefore = new Set(
terminalsBeforeRes.rows.map((r) => r.posterminal_id),
);
await runner.query(deleteCategoriesSql, { articleId });
for (const categoryId of normalized) {
await runner.query(addCategorySql, {
categoryId,
articleId,
});
}
// Find paired POS terminals where this article is visible after the category change
const terminalsAfterRes = await runner.query(listTerminalsSql, {
articleId,
companyId,
});
const terminalsAfter = new Set(
terminalsAfterRes.rows.map((r) => r.posterminal_id),
);
// 1. Terminals that had the article enabled before, but no longer have it in any enabled category:
// Record an obituary so POS sync removes the orphaned product.
for (const posterminalId of terminalsBefore) {
if (!terminalsAfter.has(posterminalId)) {
await runner.query(clearObituarySql, {
posterminalId,
entityType: 'article',
entityId: articleId,
});
await runner.query(insertObituarySql, {
posterminalId,
entityType: 'article',
entityId: articleId,
});
}
}
// 2. Terminals where the article IS enabled now:
// Clear any pending obituary so POS sync does not delete the active product.
for (const posterminalId of terminalsAfter) {
await runner.query(clearObituarySql, {
posterminalId,
entityType: 'article',
entityId: articleId,
});
}
// Write history if categories changed
await recordCategoryChangeHistory(runner, {
articleId,
companyuserId,
oldCategoryIds,
newCategoryIds: normalized,
});
// Touch article updated_at so POS and other consumers pick up category assignment changes
await runner.query(touchArticleSql, { articleId });
const article = await load(articleId, languageId, runner);
if (!article) {
throw new Error('Article not found');
}
return article;
};
if (tx) {
return runSet(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runSet);
}
return runSet(makeRunner(provider));
};
}