diff --git a/API.article.md b/API.article.md index 69b46ca..167d831 100644 --- a/API.article.md +++ b/API.article.md @@ -160,6 +160,48 @@ const history = await store.article.getHistoryBySku(42, 'SKU-1'); // => [{ id, companyuserId, date, text }] ``` +## `article.importRows` + +*Source: `src/store/article/importRows.js`* + +Imports article rows, creating articles by their SKU and returning per-row import, skip and error outcomes. The import runs in **two passes** so a `fatherSku` that references a row appearing later in the file still resolves. A row is **skipped** when its SKU already exists in the company (upsert mode is not applied), and an EAN is skipped when already used by another row. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `input` | `object` | The import input. | +| `input.googleAuthId` | `number` | The id of the Google auth user. | +| `input.rows` | `Array` | The article rows to import. | +| `input.rows[].sku` | `string` | The article SKU. | +| `input.rows[].name` | `string` | The article name. | +| `[input.rows[].vk]` | `string` | `'V'` (father/compound) or `'K'` (variant) marker. | +| `[input.rows[].parentId]` | `number|string` | The id of the parent category. | +| `[input.rows[].parentCategory]` | `string` | The category breadcrumb path, e.g. `'Flowers | CBN'`. | +| `[input.rows[].brand]` | `string` | The brand name to upsert. | +| `[input.rows[].net]` | `number|string` | The POS net price (accepts German decimals). | +| `[input.rows[].tax]` | `number|string` | The tax rate in percent (e.g. `19`). | +| `[input.rows[].eans]` | `Array|string` | The EANs to set. | +| `[input.rows[].fatherSku]` | `string` | The father article SKU for a V/K compound link. | +| `[input.rows[].deposit]` | `number|string` | The deposit (Pfand) value; currently ignored. | + +**Returns** + +`Promise<{imported: Array, skipped: Array, errors: Array}>` — The import outcome. + +**Example** + +```js +const result = await store.article.importRows({ + googleAuthId: 42, + rows: [ + { sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], fatherSku: '42001190' }, + { sku: '42001190', name: '420Strainz', vk: 'V', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '0,00', tax: 19 }, + ], +}); +// => { imported: [...], skipped: [...], errors: [...] } +``` + ## `article.listExport` *Source: `src/store/article/listExport.js`* @@ -328,6 +370,35 @@ const article = await store.article.setEansBySku(42, 'SKU-1', ['4001234567890']) // => { id, sku, name, ... } ``` +## `article.setFatherBySku` + +*Source: `src/store/article/setFatherBySku.js`* + +Links an article to a father article (V/K compound) by their SKUs. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `sku` | `string` | The child article SKU. | +| `fatherSku` | `string|null` | The father article SKU, or `null` to unlink. | + +**Returns** + +`Promise` — The updated child article. + +**Throws** + +`Error` — If the article or father is not found, or the link is invalid. + +**Example** + +```js +const article = await store.article.setFatherBySku(42, '42001190-1', '42001190'); +// => { id, sku, name, fatherSku: '42001190', ... } +``` + ## `article.setPricesBySku` *Source: `src/store/article/setPricesBySku.js`* diff --git a/API.category.md b/API.category.md index adbec74..4c57bd6 100644 --- a/API.category.md +++ b/API.category.md @@ -109,6 +109,33 @@ const result = await store.category.deleteImage(42, 5); // => { id: 5, thumbW: null, thumbH: null, thumbHash: null } ``` +## `category.ensureByPath` + +*Source: `src/store/category/ensureByPath.js`* + +Resolves a category by breadcrumb path under a given parent, creating any missing categories along the way. Returns the leaf category id. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `input` | `object` | The category resolution input. | +| `input.googleAuthId` | `number` | The id of the Google auth user. | +| `input.parentId` | `number` | The id of the parent category to start from. | +| `input.path` | `string` | The breadcrumb path, e.g. `'Haupt | Flowers | CBN'`. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise` — The id of the leaf category. + +**Example** + +```js +const id = await store.category.ensureByPath({ googleAuthId: 42, parentId: 5, path: 'Haupt | Flowers | CBN' }); +// => 12 +``` + ## `category.getCategoryArticles` *Source: `src/store/category/getCategoryArticles.js`* diff --git a/src/index.js b/src/index.js index 3dcc40a..3755b31 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,9 @@ import articleSetEansBySku from './store/article/setEansBySku.js'; import articleSetBrandIdBySku from './store/article/setBrandIdBySku.js'; import articleSetWeightBySku from './store/article/setWeightBySku.js'; import articleSetCategoryIdsBySku from './store/article/setCategoryIdsBySku.js'; +import articleSetFatherBySku from './store/article/setFatherBySku.js'; +import articleImportRows from './store/article/importRows.js'; +import categoryEnsureByPath from './store/category/ensureByPath.js'; import unitList from './store/unit/list.js'; import unitUpsert from './store/unit/upsert.js'; import unitDelete from './store/unit/delete.js'; @@ -129,6 +132,7 @@ export default class LibDashStore { getTreeForCompany: categoryGetTreeForCompany(pool), getCategoryArticles: categoryGetCategoryArticles(pool), createUnderParent: categoryCreateUnderParent(pool), + ensureByPath: categoryEnsureByPath(pool), rename: categoryRename(pool), move: categoryMove(pool), order: categoryOrder(pool), @@ -149,6 +153,8 @@ export default class LibDashStore { setBrandIdBySku: articleSetBrandIdBySku(pool), setWeightBySku: articleSetWeightBySku(pool), setCategoryIdsBySku: articleSetCategoryIdsBySku(pool), + setFatherBySku: articleSetFatherBySku(pool), + importRows: articleImportRows(pool), addImage: articleAddImage(pool), deleteImage: articleDeleteImage(pool), orderImages: articleOrderImages(pool), diff --git a/src/sql/article.js b/src/sql/article.js index fbe5f65..395d9e6 100644 --- a/src/sql/article.js +++ b/src/sql/article.js @@ -59,6 +59,39 @@ INNER JOIN categoryarticle ca ON ca.article_id = a.id WHERE a.sku = @sku `; +/** Resolve an article id by sku (used to wire father/child compound links). */ +export const GET_ID_BY_SKU = ` +SELECT TOP 1 id AS articleId +FROM article +WHERE sku = @sku +`; + +/** True if an EAN is already assigned to any article (duplicate guard). */ +export const EAN_EXISTS = ` +SELECT TOP 1 1 AS used +FROM articleean +WHERE ean = @ean +`; + +/** Link an article to its father article and mark the father. */ +export const UPDATE_FATHER_BY_SKU = ` +UPDATE article +SET fatherarticle_id = @fatherArticleId +WHERE sku = @sku +`; + +export const SET_IS_FATHER = ` +UPDATE article +SET is_father = 1 +WHERE id = @articleId +`; + +export const SET_FATHER_SORT = ` +UPDATE article +SET fathersort = @fathersort +WHERE id = @articleId AND fatherarticle_id = @fatherArticleId +`; + /** SKU → article/category + company auth in one round-trip. */ export const RESOLVE_ARTICLE_ACCESS_BY_SKU = ` WITH article_cat AS ( diff --git a/src/sql/category.js b/src/sql/category.js index 0a1e45e..b6b0730 100644 --- a/src/sql/category.js +++ b/src/sql/category.js @@ -92,6 +92,16 @@ FROM category WHERE parent_id = @parentId `; +/** Resolve a child category by its parent id and name (language-sensitive). */ +export const GET_BY_PARENT_AND_NAME = ` +SELECT c.id +FROM category c +INNER JOIN categorytext ct + ON ct.category_id = c.id AND ct.language_id = @languageId +WHERE c.parent_id = @parentId + AND ct.name = @name +`; + export const LIST_CHILD_IDS = ` SELECT id FROM category diff --git a/src/store/article/importRows.js b/src/store/article/importRows.js new file mode 100644 index 0000000..379e7ee --- /dev/null +++ b/src/store/article/importRows.js @@ -0,0 +1,303 @@ +import { + ADD_EAN, + CREATE as CREATE_ARTICLE, + EAN_EXISTS, + GET_ID_BY_SKU, + SET_FATHER_SORT, + SET_IS_FATHER, + UPDATE_BRAND, + UPDATE_FATHER_BY_SKU, +} from '../../sql/article.js'; +import { ADD as ADD_CATEGORYARTICLE } from '../../sql/categoryarticle.js'; +import { CREATE as CREATE_ARTICLETEXT } from '../../sql/articletext.js'; +import { INSERT as INSERT_HISTORY } from '../../sql/articlehistory.js'; +import { GET_CHILD_COUNT } from '../../sql/articleDelete.js'; +import { CREATE as CREATE_BRAND, GET_BY_COMPANY_AND_NAME } from '../../sql/brand.js'; +import { CREATE as CREATE_PRICE } from '../../sql/price.js'; +import { CREATE as CREATE_TAX, GET_BY_RATE } from '../../sql/tax.js'; +import request from '../request.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import ensureByPath from '../category/ensureByPath.js'; +import ensureDefaultUnit from '../unit/ensureDefault.js'; +import slugify from '../slugify.js'; + +// Normalizes a numeric value that may arrive as a string using German decimal +// notation (e.g. "11,00"), a JS number, or null/undefined. Returns null when empty. +function toNumber(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + const s = String(value).trim(); + if (s === '') return null; + const n = Number(s.replace(',', '.')); + return Number.isFinite(n) ? n : null; +} + +export default function importRows(pool) { + const companyAccess = resolveSelectedCompanyAccess(pool); + const ensureUnit = ensureDefaultUnit(pool); + const ensureCategoryByPath = ensureByPath(pool); + + // Find or create a brand for a company by name (within the current tx). + async function ensureBrand(tx, companyId, name) { + const existing = await request(pool, tx) + .input('companyId', companyId) + .input('name', name) + .query(GET_BY_COMPANY_AND_NAME); + + if (existing.recordset[0]) { + return existing.recordset[0].id; + } + + const created = await request(pool, tx) + .input('companyId', companyId) + .input('name', name) + .input('gsprpinfo', null) + .query(CREATE_BRAND); + + return created.recordset[0].id; + } + + /** + * Imports article rows, creating articles by their SKU and returning per-row + * import, skip and error outcomes. + * + * The import runs in **two passes** so a `fatherSku` that references a row + * appearing later in the file still resolves. A row is **skipped** when its + * SKU already exists in the company (upsert mode is not applied), and an EAN + * is skipped when already used by another row. + * + * @param {object} input - The import input. + * @param {number} input.googleAuthId - The id of the Google auth user. + * @param {Array} input.rows - The article rows to import. + * @param {string} input.rows[].sku - The article SKU. + * @param {string} input.rows[].name - The article name. + * @param {string} [input.rows[].vk] - `'V'` (father/compound) or `'K'` (variant) marker. + * @param {number|string} [input.rows[].parentId] - The id of the parent category. + * @param {string} [input.rows[].parentCategory] - The category breadcrumb path, e.g. `'Flowers | CBN'`. + * @param {string} [input.rows[].brand] - The brand name to upsert. + * @param {number|string} [input.rows[].net] - The POS net price (accepts German decimals). + * @param {number|string} [input.rows[].tax] - The tax rate in percent (e.g. `19`). + * @param {Array|string} [input.rows[].eans] - The EANs to set. + * @param {string} [input.rows[].fatherSku] - The father article SKU for a V/K compound link. + * @param {number|string} [input.rows[].deposit] - The deposit (Pfand) value; currently ignored. + * @returns {Promise<{imported: Array, skipped: Array, errors: Array}>} The import outcome. + * + * @example + * const result = await store.article.importRows({ + * googleAuthId: 42, + * rows: [ + * { sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], fatherSku: '42001190' }, + * { sku: '42001190', name: '420Strainz', vk: 'V', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '0,00', tax: 19 }, + * ], + * }); + * // => { imported: [...], skipped: [...], errors: [...] } + */ + return async ({ googleAuthId, rows }) => { + const { + companyId, + languageId, + rootCategoryId, + posCustomerGroupId, + companyuserId, + } = await companyAccess(googleAuthId); + + if (!Array.isArray(rows)) { + throw new Error('Invalid rows'); + } + + const imported = []; + const skipped = []; + const errors = []; + const usedEans = new Set(); + + // Pass 1: create articles (skip existing SKUs). + for (const row of rows) { + const sku = String(row?.sku ?? '').trim(); + const name = String(row?.name ?? '').trim(); + + if (!sku || !name) { + errors.push({ sku: sku || null, name: name || null, error: 'Invalid row' }); + continue; + } + + const existing = await pool.request().input('sku', sku).query(GET_ID_BY_SKU); + if (existing.recordset[0]) { + skipped.push({ sku, name, reason: 'duplicate sku' }); + continue; + } + + let tx; + try { + tx = pool.transaction(); + await tx.begin(); + + const unitId = await ensureUnit(tx, companyId, languageId); + const slug = slugify(name); + + const article = await request(pool, tx) + .input('sku', sku) + .input('unitId', unitId) + .input('unitamount', 1) + .input('weight', 0) + .query(CREATE_ARTICLE); + + const articleId = article.recordset[0].id; + + // Resolve the parent category id (explicit id or breadcrumb path). + let parentId = row.parentId; + if (parentId == null && row.parentCategory) { + parentId = await ensureCategoryByPath( + { googleAuthId, parentId: rootCategoryId, path: row.parentCategory }, + tx, + ); + } + if (parentId == null) { + throw new Error('Parent category required'); + } + + await request(pool, tx) + .input('categoryId', parentId) + .input('articleId', articleId) + .query(ADD_CATEGORYARTICLE); + + await request(pool, tx) + .input('articleId', articleId) + .input('languageId', languageId) + .input('name', name) + .input('slug', slug) + .query(CREATE_ARTICLETEXT); + + await request(pool, tx) + .input('articleId', articleId) + .input('companyuserId', companyuserId) + .input('text', `Artikel "${name}" importiert`) + .query(INSERT_HISTORY); + + // Brand (upsert by name). + const brandName = row.brand != null ? String(row.brand).trim() : ''; + if (brandName) { + const brandId = await ensureBrand(tx, companyId, brandName); + await request(pool, tx) + .input('articleId', articleId) + .input('brandId', brandId) + .query(UPDATE_BRAND); + } + + // POS price (net/tax). + const net = toNumber(row.net); + const taxRate = toNumber(row.tax) ?? 0; + if (net != null) { + const taxResult = await request(pool, tx) + .input('rate', taxRate) + .query(GET_BY_RATE); + let taxId = taxResult.recordset[0]?.id; + if (!taxId) { + const created = await request(pool, tx) + .input('rate', taxRate) + .query(CREATE_TAX); + taxId = created.recordset[0].id; + } + await request(pool, tx) + .input('taxId', taxId) + .input('articleId', articleId) + .input('minmultiplier', 1) + .input('customerGroupId', posCustomerGroupId) + .input('net', net) + .query(CREATE_PRICE); + } + + // EANs (array, or a single value; skip duplicates within the import). + const rawEans = row.eans; + const eans = Array.isArray(rawEans) + ? rawEans + : rawEans == null || rawEans === '' + ? [] + : [rawEans]; + for (const ean of eans) { + const clean = String(ean ?? '').trim(); + if (!clean || usedEans.has(clean)) continue; + const used = await request(pool, tx) + .input('ean', clean) + .query(EAN_EXISTS); + if (used.recordset[0]) continue; + await request(pool, tx) + .input('articleId', articleId) + .input('ean', clean) + .query(ADD_EAN); + usedEans.add(clean); + } + + await tx.commit(); + imported.push({ id: articleId, sku, name }); + } catch (err) { + if (tx) { + try { + await tx.rollback(); + } catch { + // ignore + } + } + errors.push({ sku, name, error: err.message ?? String(err) }); + } + } + + // Pass 2: wire V/K father links (fathers may be created in pass 1). + for (const row of rows) { + const sku = String(row?.sku ?? '').trim(); + const fatherSku = + row?.fatherSku != null ? String(row.fatherSku).trim() : ''; + if (!sku || !fatherSku) continue; + + let tx; + try { + tx = pool.transaction(); + await tx.begin(); + + const child = await request(pool, tx) + .input('sku', sku) + .query(GET_ID_BY_SKU); + const childId = child.recordset[0]?.articleId; + if (childId) { + const father = await request(pool, tx) + .input('sku', fatherSku) + .query(GET_ID_BY_SKU); + const fatherId = father.recordset[0]?.articleId; + if (fatherId && fatherId !== childId) { + await request(pool, tx) + .input('sku', sku) + .input('fatherArticleId', fatherId) + .query(UPDATE_FATHER_BY_SKU); + await request(pool, tx) + .input('articleId', fatherId) + .query(SET_IS_FATHER); + const childCount = await request(pool, tx) + .input('articleId', fatherId) + .query(GET_CHILD_COUNT); + const count = childCount.recordset[0].count; + // The child's sort is its 0-based position among the father's children. + await request(pool, tx) + .input('articleId', childId) + .input('fatherArticleId', fatherId) + .input('fathersort', count - 1) + .query(SET_FATHER_SORT); + } + } + + await tx.commit(); + } catch { + if (tx) { + try { + await tx.rollback(); + } catch { + // ignore + } + } + // Non-fatal: article was already imported in pass 1. + } + } + + return { imported, skipped, errors }; + }; +} diff --git a/src/store/article/setFatherBySku.js b/src/store/article/setFatherBySku.js new file mode 100644 index 0000000..97babbe --- /dev/null +++ b/src/store/article/setFatherBySku.js @@ -0,0 +1,98 @@ +import { + GET_ID_BY_SKU, + SET_FATHER_SORT, + SET_IS_FATHER, + UPDATE_FATHER_BY_SKU, +} from '../../sql/article.js'; +import { GET_CHILD_COUNT } from '../../sql/articleDelete.js'; +import request from '../request.js'; +import queryBySku from './queryBySku.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; + +export default function setFatherBySku(pool) { + const resolve = resolveArticleBySku(pool); + const load = queryBySku(pool); + + /** + * Links an article to a father article (V/K compound) by their SKUs. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {string} sku - The child article SKU. + * @param {string|null} fatherSku - The father article SKU, or `null` to unlink. + * @returns {Promise} The updated child article. + * @throws {Error} If the article or father is not found, or the link is invalid. + * + * @example + * const article = await store.article.setFatherBySku(42, '42001190-1', '42001190'); + * // => { id, sku, name, fatherSku: '42001190', ... } + */ + return async (googleAuthId, sku, fatherSku) => { + const { articleId, languageId } = await resolve(googleAuthId, sku); + + if (fatherSku == null || fatherSku === '') { + await request(pool) + .input('sku', sku) + .input('fatherArticleId', null) + .query(UPDATE_FATHER_BY_SKU); + const unlinked = await load(articleId, languageId); + if (!unlinked) { + throw new Error('Article not found'); + } + return unlinked; + } + + const father = await pool + .request() + .input('sku', fatherSku) + .query(GET_ID_BY_SKU); + const fatherId = father.recordset[0]?.articleId; + if (!fatherId) { + throw new Error('Father not found'); + } + if (fatherId === articleId) { + throw new Error('Invalid father'); + } + + const tx = pool.transaction(); + await tx.begin(); + + try { + await request(pool, tx) + .input('sku', sku) + .input('fatherArticleId', fatherId) + .query(UPDATE_FATHER_BY_SKU); + + await request(pool, tx) + .input('articleId', fatherId) + .query(SET_IS_FATHER); + + const childCount = await request(pool, tx) + .input('articleId', fatherId) + .query(GET_CHILD_COUNT); + const count = childCount.recordset[0].count; + + // The child's sort is its 0-based position among the father's children. + await request(pool, tx) + .input('articleId', articleId) + .input('fatherArticleId', fatherId) + .input('fathersort', count - 1) + .query(SET_FATHER_SORT); + + await tx.commit(); + } catch (err) { + try { + await tx.rollback(); + } catch { + // ignore + } + throw err; + } + + const article = await load(articleId, languageId); + if (!article) { + throw new Error('Article not found'); + } + + return article; + }; +} diff --git a/src/store/category/ensureByPath.js b/src/store/category/ensureByPath.js new file mode 100644 index 0000000..f508ec0 --- /dev/null +++ b/src/store/category/ensureByPath.js @@ -0,0 +1,107 @@ +import { + CREATE, + GET_BY_PARENT_AND_NAME, + GET_NEXT_SIBLING_SORT, +} from '../../sql/category.js'; +import { CREATE as CREATE_CATEGORYTEXT } from '../../sql/categorytext.js'; +import resolveCategoryAccess from './resolveCategoryAccess.js'; +import request from '../request.js'; +import slugify from '../slugify.js'; + +// Splits a "A | B | C" breadcrumb string into trimmed, non-empty name parts, +// ordered root-first. +export function splitPath(path) { + return String(path ?? '') + .split('|') + .map((part) => part.trim()) + .filter(Boolean); +} + +export default function ensureByPath(pool) { + const access = resolveCategoryAccess(pool); + + /** + * Resolves a category by breadcrumb path under a given parent, creating any + * missing categories along the way. Returns the leaf category id. + * + * @param {object} input - The category resolution input. + * @param {number} input.googleAuthId - The id of the Google auth user. + * @param {number} input.parentId - The id of the parent category to start from. + * @param {string} input.path - The breadcrumb path, e.g. `'Haupt | Flowers | CBN'`. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise} The id of the leaf category. + * + * @example + * const id = await store.category.ensureByPath({ googleAuthId: 42, parentId: 5, path: 'Haupt | Flowers | CBN' }); + * // => 12 + */ + return async ({ googleAuthId, parentId, path }, tx) => { + const names = splitPath(path); + if (names.length === 0) { + throw new Error('Invalid category path'); + } + + let currentId = parentId; + + const createCategory = async (tx, parentId, name, languageId) => { + const sortResult = await request(pool, tx) + .input('parentId', parentId) + .query(GET_NEXT_SIBLING_SORT); + const sort = sortResult.recordset[0].sort; + const slug = slugify(name); + + const category = await request(pool, tx) + .input('parentId', parentId) + .input('sort', sort) + .query(CREATE); + const id = category.recordset[0].id; + + await request(pool, tx) + .input('categoryId', id) + .input('languageId', languageId) + .input('name', name) + .input('slug', slug) + .query(CREATE_CATEGORYTEXT); + + return id; + }; + + for (const name of names) { + const { languageId } = await access(googleAuthId, currentId); + + const existing = await request(pool, tx) + .input('parentId', currentId) + .input('languageId', languageId) + .input('name', name) + .query(GET_BY_PARENT_AND_NAME); + + if (existing.recordset[0]) { + currentId = existing.recordset[0].id; + continue; + } + + if (tx) { + // Run within the caller's transaction (e.g. article import row). + currentId = await createCategory(tx, currentId, name, languageId); + continue; + } + + // Standalone use: own transaction per created category. + const ownTx = pool.transaction(); + await ownTx.begin(); + try { + currentId = await createCategory(ownTx, currentId, name, languageId); + await ownTx.commit(); + } catch (err) { + try { + await ownTx.rollback(); + } catch { + // ignore + } + throw err; + } + } + + return currentId; + }; +}