diff --git a/README.md b/README.md index b089aa6..2f12410 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ Company-scoped POS terminals. Auth via `resolveCompanyAccess`. | Method | Signature | Returns | | --- | --- | --- | | `listSearchIndex` | `(googleAuthId: string, companyId: number)` | `SearchIndexEntry[]` (see below) | +| `listExport` | `(googleAuthId: string, companyId: number, categoryId?: number)` | `ArticleExportEntry[]` (see below) | | `createUnderParent` | `({ googleAuthId: string, parentId: number, name: string })` | `{ id: number, parentId: number, name: string, sku: string }` | | `getBySku` | `(googleAuthId: string, sku: string)` | `ArticleDetail` (see below) | | `updateBySku` | `(googleAuthId: string, sku: string, { name?: string, unitamount?: number, unitSi?: string })` | `ArticleDetail` | @@ -304,6 +305,27 @@ type SearchIndexEntry = { } ``` +`listExport` returns articles in a category subtree (root = company when `categoryId` omitted). `categoryId` must be reachable via the selected company's root category (else `Not authorized`). Auth via `resolveCompanyAccess` + `resolveCategoryAccess`. Errors: `Company not found`, `Parent not found`, `Not authorized`. + +```ts +type ArticleExportEntry = { + id: number, + sku: string, + name: string | null, // null when articletext row is missing + slug: string | null, + unitSi: string | null, + unitSymbol: string | null, + unitamount: number, + brandName: string | null, + net: number | null, // null when no POS price + taxRate: number | null, // null when no POS price + eans: string[], // all EANs, empty array when none + categoryIds: number[], // all category ids, at least one + categoryBreadcrumbs: string[], // one middot-separated breadcrumb ("Home·Cat·Sub") per category + fullHashes: string[], // all photo fullHashes in sort order, empty when none +} +``` + `getBySku` / `updateBySku` / `setPricesBySku` / `setEansBySku` / `setBrandIdBySku` / `setWeightBySku` / `setCategoryIdsBySku` all return `ArticleDetail`: ```ts @@ -413,6 +435,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `DELETE /api/company/:id` | `company.delete(id)` | | `PUT /api/auth/selected-company` | `google_auth.setSelectedCompany(googleAuthId, companyId)` | | `GET /api/category/tree` | `category.getTreeForCompany(googleAuthId, companyId)` | +| `GET /api/category/export.zip` | `category.getTreeForCompany(googleAuthId, companyId)` + `pic.getImageByHash` | | `POST /api/category` | `category.createUnderParent({ googleAuthId, parentId, name })` | | `PATCH /api/category/:id` | `category.rename({ googleAuthId, id, name })` | | `PUT /api/category/order` | `category.order({ googleAuthId, parentId, categoryIds })` | @@ -438,7 +461,8 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer); | `DELETE /api/posterminal/:id/categories` | `posterminal.setOffAll(googleAuthId, companyId, id)` | | `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` | | `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` | -| `GET /api/articles/search-index` | `article.listSearchIndex(googleAuthId, companyId)` | +| `GET /api/articleIndex` | `article.listSearchIndex(googleAuthId, companyId)` | +| `GET /api/article/export.zip` | `article.listExport(googleAuthId, companyId, categoryId?)` | | `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name?, unitamount?, unitSi? })` | | `PUT /api/article/:sku/prices` | `article.setPricesBySku(googleAuthId, sku, [{ price, tax, minmultiplier? }])` | | `PUT /api/article/:sku/eans` | `article.setEansBySku(googleAuthId, sku, string[])` | diff --git a/src/index.js b/src/index.js index 63f6f1c..7c3c12a 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ import articleAddImage from './store/article/addImage.js'; import articleCreateUnderParent from './store/article/createUnderParent.js'; import articleGetBySku from './store/article/getBySku.js'; import articleListSearchIndex from './store/article/listSearchIndex.js'; +import articleListExport from './store/article/listExport.js'; import articleUpdateBySku from './store/article/updateBySku.js'; import articleSetPricesBySku from './store/article/setPricesBySku.js'; import articleSetEansBySku from './store/article/setEansBySku.js'; @@ -97,6 +98,7 @@ export default class LibDashStore { createUnderParent: articleCreateUnderParent(pool), getBySku: articleGetBySku(pool), listSearchIndex: articleListSearchIndex(pool), + listExport: articleListExport(pool), updateBySku: articleUpdateBySku(pool), setPricesBySku: articleSetPricesBySku(pool), setEansBySku: articleSetEansBySku(pool), diff --git a/src/sql/article.js b/src/sql/article.js index a1bd579..fbe5f65 100644 --- a/src/sql/article.js +++ b/src/sql/article.js @@ -207,6 +207,100 @@ WHERE EXISTS ( ORDER BY a.id `; +/** + * Flat category tree (id, parentId, name) for a root category, used to resolve + * breadcrumbs for the article export. + */ +export const GET_CATEGORY_TREE_FLAT = ` +;WITH tree AS ( + SELECT id, parent_id, sort + FROM category + WHERE id = @rootId + + UNION ALL + + SELECT c.id, c.parent_id, c.sort + FROM category c + INNER JOIN tree t ON c.parent_id = t.id +) +SELECT + t.id, + t.parent_id AS parentId, + ct.name +FROM tree t +LEFT JOIN categorytext ct + ON ct.category_id = t.id AND ct.language_id = @languageId +`; + +/** + * Export rows for every article in a category subtree (root = @categoryId). + * One row per article with the columns used by the export CSV, including the + * POS price (minmultiplier = 1 for the company poscustomergroup), brand, unit, + * eans, categoryIds and all photo hashes as JSON arrays. + */ +export const GET_ARTICLE_EXPORT = ` +;WITH category_tree AS ( + SELECT id + FROM category + WHERE id = @categoryId + + UNION ALL + + SELECT c.id + FROM category c + INNER JOIN category_tree ct ON c.parent_id = ct.id +) +SELECT + a.id, + a.sku, + at.name, + at.slug, + u.si AS unitSi, + ut.symbol AS unitSymbol, + a.unitamount, + b.name AS brandName, + b.name AS brandName, + pr.net AS net, + t.rate AS taxRate, + ( + SELECT ae.ean + FROM articleean ae + WHERE ae.article_id = a.id + ORDER BY ae.ean + FOR JSON PATH + ) AS eansJson, + ( + SELECT ca2.category_id + FROM categoryarticle ca2 + WHERE ca2.article_id = a.id + ORDER BY ca2.category_id + FOR JSON PATH + ) AS categoryIdsJson, + ( + SELECT p.hash + FROM articlepic ap + INNER JOIN pic p ON p.id = ap.pic_id + WHERE ap.article_id = a.id + ORDER BY ap.sort ASC, ap.id ASC + FOR JSON PATH + ) AS fullHashesJson +FROM article a +INNER JOIN categoryarticle ca ON ca.article_id = a.id +INNER JOIN category_tree ct ON ct.id = ca.category_id +LEFT JOIN articletext at + ON at.article_id = a.id AND at.language_id = @languageId +LEFT JOIN unit u ON u.id = a.unit_id +LEFT JOIN unittext ut + ON ut.unit_id = u.id AND ut.language_id = @languageId +LEFT JOIN brand b ON b.id = a.brand_id +LEFT JOIN price pr + ON pr.article_id = a.id + AND pr.customergroup_id = @posCustomerGroupId + AND pr.minmultiplier = 1 +LEFT JOIN tax t ON t.id = pr.tax_id +ORDER BY a.id +`; + /** * Single-query replacement for resolveArticleBySku + queryBySku. * Resolves SKU → article + company + auth, then loads full article detail diff --git a/src/store/article/listExport.js b/src/store/article/listExport.js new file mode 100644 index 0000000..6f3b40e --- /dev/null +++ b/src/store/article/listExport.js @@ -0,0 +1,93 @@ +import { GET_ARTICLE_EXPORT, GET_CATEGORY_TREE_FLAT } from '../../sql/article.js'; +import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; + +const ROOT_CATEGORY_NAME = 'Home'; + +function parseJsonArray(raw) { + if (!raw) return []; + const arr = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(arr) ? arr : []; +} + +export default function listExport(pool) { + const companyAccess = resolveCompanyAccess(pool); + const categoryAccess = resolveCategoryAccess(pool); + + return async (googleAuthId, companyId, categoryId) => { + const { languageId, posCustomerGroupId, rootCategoryId } = await companyAccess( + googleAuthId, + companyId, + ); + + // Default to the company's root category when no specific category is given. + let exportCategoryId = rootCategoryId; + + if (categoryId != null) { + // The requested category must be reachable via the selected company's root. + const access = await categoryAccess(googleAuthId, categoryId); + if (access.rootCategoryId !== rootCategoryId) { + throw new Error('Not authorized'); + } + exportCategoryId = categoryId; + } + + const [result, treeResult] = await Promise.all([ + pool + .request() + .input('categoryId', exportCategoryId) + .input('languageId', languageId) + .input('posCustomerGroupId', posCustomerGroupId) + .query(GET_ARTICLE_EXPORT), + pool + .request() + .input('rootId', rootCategoryId) + .input('languageId', languageId) + .query(GET_CATEGORY_TREE_FLAT), + ]); + + // id -> { id, parentId, name } for breadcrumb resolution. + const catById = new Map(); + for (const node of treeResult.recordset) { + catById.set(String(node.id), node); + } + + // Breadcrumb label for one category id: "Home" for root, else trimmed name. + const displayName = (node) => + String(node.id) === String(rootCategoryId) ? ROOT_CATEGORY_NAME : (node.name ?? '').trim(); + + const breadcrumb = (categoryId) => { + const crumbs = []; + const seen = new Set(); + let id = categoryId; + while (id != null && !seen.has(String(id))) { + seen.add(String(id)); + const node = catById.get(String(id)); + if (!node) break; + crumbs.unshift(displayName(node)); + id = node.parentId; + } + // A category outside the tree still yields its own label. + return crumbs.join('·'); + }; + + return result.recordset.map((row) => ({ + id: row.id, + sku: row.sku, + name: row.name, + slug: row.slug, + unitSi: row.unitSi, + unitSymbol: row.unitSymbol, + unitamount: row.unitamount, + brandName: row.brandName, + net: row.net, + taxRate: row.taxRate, + eans: parseJsonArray(row.eansJson).map((r) => r.ean), + categoryIds: parseJsonArray(row.categoryIdsJson).map((r) => r.category_id), + categoryBreadcrumbs: parseJsonArray(row.categoryIdsJson) + .map((r) => breadcrumb(r.category_id)) + .filter(Boolean), + fullHashes: parseJsonArray(row.fullHashesJson).map((r) => r.hash), + })); + }; +}