export
This commit is contained in:
26
README.md
26
README.md
@@ -264,6 +264,7 @@ Company-scoped POS terminals. Auth via `resolveCompanyAccess`.
|
|||||||
| Method | Signature | Returns |
|
| Method | Signature | Returns |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `listSearchIndex` | `(googleAuthId: string, companyId: number)` | `SearchIndexEntry[]` (see below) |
|
| `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 }` |
|
| `createUnderParent` | `({ googleAuthId: string, parentId: number, name: string })` | `{ id: number, parentId: number, name: string, sku: string }` |
|
||||||
| `getBySku` | `(googleAuthId: string, sku: string)` | `ArticleDetail` (see below) |
|
| `getBySku` | `(googleAuthId: string, sku: string)` | `ArticleDetail` (see below) |
|
||||||
| `updateBySku` | `(googleAuthId: string, sku: string, { name?: string, unitamount?: number, unitSi?: string })` | `ArticleDetail` |
|
| `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`:
|
`getBySku` / `updateBySku` / `setPricesBySku` / `setEansBySku` / `setBrandIdBySku` / `setWeightBySku` / `setCategoryIdsBySku` all return `ArticleDetail`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -413,6 +435,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer);
|
|||||||
| `DELETE /api/company/:id` | `company.delete(id)` |
|
| `DELETE /api/company/:id` | `company.delete(id)` |
|
||||||
| `PUT /api/auth/selected-company` | `google_auth.setSelectedCompany(googleAuthId, companyId)` |
|
| `PUT /api/auth/selected-company` | `google_auth.setSelectedCompany(googleAuthId, companyId)` |
|
||||||
| `GET /api/category/tree` | `category.getTreeForCompany(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 })` |
|
| `POST /api/category` | `category.createUnderParent({ googleAuthId, parentId, name })` |
|
||||||
| `PATCH /api/category/:id` | `category.rename({ googleAuthId, id, name })` |
|
| `PATCH /api/category/:id` | `category.rename({ googleAuthId, id, name })` |
|
||||||
| `PUT /api/category/order` | `category.order({ googleAuthId, parentId, categoryIds })` |
|
| `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)` |
|
| `DELETE /api/posterminal/:id/categories` | `posterminal.setOffAll(googleAuthId, companyId, id)` |
|
||||||
| `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` |
|
| `POST /api/article` | `article.createUnderParent({ googleAuthId, parentId, name })` |
|
||||||
| `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` |
|
| `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? })` |
|
| `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/prices` | `article.setPricesBySku(googleAuthId, sku, [{ price, tax, minmultiplier? }])` |
|
||||||
| `PUT /api/article/:sku/eans` | `article.setEansBySku(googleAuthId, sku, string[])` |
|
| `PUT /api/article/:sku/eans` | `article.setEansBySku(googleAuthId, sku, string[])` |
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import articleAddImage from './store/article/addImage.js';
|
|||||||
import articleCreateUnderParent from './store/article/createUnderParent.js';
|
import articleCreateUnderParent from './store/article/createUnderParent.js';
|
||||||
import articleGetBySku from './store/article/getBySku.js';
|
import articleGetBySku from './store/article/getBySku.js';
|
||||||
import articleListSearchIndex from './store/article/listSearchIndex.js';
|
import articleListSearchIndex from './store/article/listSearchIndex.js';
|
||||||
|
import articleListExport from './store/article/listExport.js';
|
||||||
import articleUpdateBySku from './store/article/updateBySku.js';
|
import articleUpdateBySku from './store/article/updateBySku.js';
|
||||||
import articleSetPricesBySku from './store/article/setPricesBySku.js';
|
import articleSetPricesBySku from './store/article/setPricesBySku.js';
|
||||||
import articleSetEansBySku from './store/article/setEansBySku.js';
|
import articleSetEansBySku from './store/article/setEansBySku.js';
|
||||||
@@ -97,6 +98,7 @@ export default class LibDashStore {
|
|||||||
createUnderParent: articleCreateUnderParent(pool),
|
createUnderParent: articleCreateUnderParent(pool),
|
||||||
getBySku: articleGetBySku(pool),
|
getBySku: articleGetBySku(pool),
|
||||||
listSearchIndex: articleListSearchIndex(pool),
|
listSearchIndex: articleListSearchIndex(pool),
|
||||||
|
listExport: articleListExport(pool),
|
||||||
updateBySku: articleUpdateBySku(pool),
|
updateBySku: articleUpdateBySku(pool),
|
||||||
setPricesBySku: articleSetPricesBySku(pool),
|
setPricesBySku: articleSetPricesBySku(pool),
|
||||||
setEansBySku: articleSetEansBySku(pool),
|
setEansBySku: articleSetEansBySku(pool),
|
||||||
|
|||||||
@@ -207,6 +207,100 @@ WHERE EXISTS (
|
|||||||
ORDER BY a.id
|
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.
|
* Single-query replacement for resolveArticleBySku + queryBySku.
|
||||||
* Resolves SKU → article + company + auth, then loads full article detail
|
* Resolves SKU → article + company + auth, then loads full article detail
|
||||||
|
|||||||
93
src/store/article/listExport.js
Normal file
93
src/store/article/listExport.js
Normal file
@@ -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),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user