feat(article): add article import with V/K compound linking

Add article.importRows to create articles by SKU from CSV-style rows,
running in two passes so fatherSku links to rows later in the file still
resolve. Existing SKUs are skipped, duplicate EANs are dropped, prices and
taxes are ensured, brands are upserted by name, and parent categories are
resolved/created via breadcrumb path.

Also add:
- article.setFatherBySku to link/unlink a child to its father article
- category.ensureByPath to resolve or create categories by path (tx-aware)
- supporting SQL constants (GET_ID_BY_SKU, EAN_EXISTS, father-link helpers,
  GET_BY_PARENT_AND_NAME)
- regenerated API docs
This commit is contained in:
seb
2026-08-11 05:36:07 +02:00
parent fb88cb92a5
commit af416246b4
8 changed files with 655 additions and 0 deletions

View File

@@ -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<object>} 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>|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<object>, skipped: Array<object>, errors: Array<object>}>} 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 };
};
}

View File

@@ -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<object>} 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;
};
}