Add customergroup sort with bulk order and locked implicit names.

Reorder via customergroup.order (exact id list → sort 0…n-1); block renaming POS/Lieferanten while still allowing create/update of other groups by name.
This commit is contained in:
seb
2026-08-07 03:38:01 +02:00
parent b095ab6199
commit c290fe8f4b
13 changed files with 229 additions and 59 deletions

View File

@@ -42,6 +42,7 @@ import brandDelete from './store/brand/delete.js';
import customergroupList from './store/customergroup/list.js';
import customergroupCreate from './store/customergroup/create.js';
import customergroupUpdate from './store/customergroup/update.js';
import customergroupOrder from './store/customergroup/order.js';
import customergroupDelete from './store/customergroup/delete.js';
import entityList from './store/entity/list.js';
import entityGetById from './store/entity/getById.js';
@@ -181,6 +182,7 @@ export default class LibDashStore {
list: customergroupList(pool),
create: customergroupCreate(pool),
update: customergroupUpdate(pool),
order: customergroupOrder(pool),
delete: customergroupDelete(pool),
};

View File

@@ -130,6 +130,7 @@ BEGIN
CREATE TABLE customergroup (
id INT IDENTITY(1,1) PRIMARY KEY,
name NVARCHAR(255) NOT NULL,
sort INT NOT NULL CONSTRAINT DF_customergroup_sort DEFAULT 0,
-- Nullable only for company-create chicken-egg (POS group first, then company, then SET).
-- Always set before commit; never left null for live rows.
company_id INT NULL,

View File

@@ -1,13 +1,13 @@
export const CREATE = `
INSERT INTO customergroup (name, company_id)
OUTPUT INSERTED.id, INSERTED.name
VALUES (@name, @companyId)
INSERT INTO customergroup (name, company_id, sort)
OUTPUT INSERTED.id, INSERTED.name, INSERTED.sort
VALUES (@name, @companyId, @sort)
`;
export const CREATE_WITHOUT_COMPANY = `
INSERT INTO customergroup (name)
OUTPUT INSERTED.id
VALUES (@name)
INSERT INTO customergroup (name, sort)
OUTPUT INSERTED.id, INSERTED.name, INSERTED.sort
VALUES (@name, @sort)
`;
export const SET_COMPANY = `
@@ -17,28 +17,46 @@ WHERE id = @id
`;
export const GET_BY_COMPANY_AND_NAME = `
SELECT id, name
SELECT id, name, sort
FROM customergroup
WHERE company_id = @companyId AND name = @name
`;
export const LIST_BY_COMPANY = `
SELECT id, name
SELECT id, name, sort
FROM customergroup
WHERE company_id = @companyId
ORDER BY name, id
ORDER BY sort ASC, name ASC, id ASC
`;
export const GET_BY_COMPANY_AND_ID = `
SELECT id, name
SELECT id, name, sort
FROM customergroup
WHERE id = @id AND company_id = @companyId
`;
export const UPDATE_NAME = `
export const GET_NEXT_SORT = `
SELECT ISNULL(MAX(sort), -1) + 1 AS sort
FROM customergroup
WHERE company_id = @companyId
`;
export const LIST_IDS_BY_COMPANY = `
SELECT id
FROM customergroup
WHERE company_id = @companyId
`;
export const UPDATE = `
UPDATE customergroup
SET name = @name
OUTPUT INSERTED.id, INSERTED.name
OUTPUT INSERTED.id, INSERTED.name, INSERTED.sort
WHERE id = @id AND company_id = @companyId
`;
export const UPDATE_SORT = `
UPDATE customergroup
SET sort = @sort
WHERE id = @id AND company_id = @companyId
`;

View File

@@ -1,3 +1,4 @@
import { POS_GROUP_NAME } from '../../sql/entity.js';
import { CREATE } from '../../sql/company.js';
import { CREATE as CREATE_CATEGORY } from '../../sql/category.js';
import {
@@ -10,8 +11,6 @@ import langEnsure from '../lang/ensure.js';
import taxEnsure from '../tax/ensure.js';
import request from '../request.js';
const POS_CUSTOMERGROUP_NAME = 'POS';
export default function create(pool) {
const nameTaken = getByName(pool);
const ensureLang = langEnsure(pool);
@@ -42,7 +41,8 @@ export default function create(pool) {
// Circular FK: create POS group first (company_id null), company with
// poscustomergroup_id NOT NULL, then link the group back to the company.
const posGroup = await request(pool, tx)
.input('name', POS_CUSTOMERGROUP_NAME)
.input('name', POS_GROUP_NAME)
.input('sort', 0)
.query(CREATE_CUSTOMERGROUP);
const posCustomerGroupId = posGroup.recordset[0].id;

View File

@@ -1,6 +1,13 @@
import { CREATE } from '../../sql/customergroup.js';
import { CREATE, GET_NEXT_SORT } from '../../sql/customergroup.js';
import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../sql/entity.js';
import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
const RESERVED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]);
function shape(row) {
return { id: row.id, name: row.name, sort: row.sort };
}
export default function create(pool) {
const access = resolveCompanyAccess(pool);
@@ -9,16 +16,25 @@ export default function create(pool) {
if (!cleaned) {
throw new Error('Name required');
}
if (RESERVED_NAMES.has(cleaned)) {
throw new Error('Name not allowed');
}
await access(googleAuthId, companyId);
const next = await pool
.request()
.input('companyId', companyId)
.query(GET_NEXT_SORT);
const sort = next.recordset[0].sort;
const result = await pool
.request()
.input('companyId', companyId)
.input('name', cleaned)
.input('sort', sort)
.query(CREATE);
const row = result.recordset[0];
return { id: row.id, name: row.name };
return shape(result.recordset[0]);
};
}

View File

@@ -15,6 +15,7 @@ export default function list(pool) {
return result.recordset.map((row) => ({
id: row.id,
name: row.name,
sort: row.sort,
}));
};
}

View File

@@ -0,0 +1,65 @@
import {
LIST_IDS_BY_COMPANY,
UPDATE_SORT,
} from '../../sql/customergroup.js';
import request from '../request.js';
import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
export default function order(pool) {
const access = resolveCompanyAccess(pool);
return async (googleAuthId, companyId, { customergroupIds } = {}) => {
if (!Array.isArray(customergroupIds)) {
throw new Error('Invalid customer group order');
}
await access(googleAuthId, companyId);
const groups = await pool
.request()
.input('companyId', companyId)
.query(LIST_IDS_BY_COMPANY);
const existing = new Set(groups.recordset.map((r) => r.id));
if (customergroupIds.length !== existing.size) {
throw new Error('Invalid customer group order');
}
const seen = new Set();
for (const customergroupId of customergroupIds) {
if (
!Number.isFinite(customergroupId) ||
!existing.has(customergroupId) ||
seen.has(customergroupId)
) {
throw new Error('Invalid customer group order');
}
seen.add(customergroupId);
}
const tx = pool.transaction();
await tx.begin();
try {
for (let sort = 0; sort < customergroupIds.length; sort++) {
await request(pool, tx)
.input('id', customergroupIds[sort])
.input('companyId', companyId)
.input('sort', sort)
.query(UPDATE_SORT);
}
await tx.commit();
return { customergroupIds };
} catch (err) {
try {
await tx.rollback();
} catch {
// ignore
}
throw err;
}
};
}

View File

@@ -1,6 +1,13 @@
import { UPDATE_NAME } from '../../sql/customergroup.js';
import { GET_BY_COMPANY_AND_ID, UPDATE } from '../../sql/customergroup.js';
import { POS_GROUP_NAME, SUPPLIER_GROUP_NAME } from '../../sql/entity.js';
import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
const LOCKED_NAMES = new Set([POS_GROUP_NAME, SUPPLIER_GROUP_NAME]);
function shape(row) {
return { id: row.id, name: row.name, sort: row.sort };
}
export default function update(pool) {
const access = resolveCompanyAccess(pool);
@@ -9,24 +16,39 @@ export default function update(pool) {
throw new Error('Customer group not found');
}
const cleaned = name?.trim();
const cleaned = name?.trim?.() ? name.trim() : null;
if (!cleaned) {
throw new Error('Name required');
}
await access(googleAuthId, companyId);
const existing = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.query(GET_BY_COMPANY_AND_ID);
const current = existing.recordset[0];
if (!current) {
throw new Error('Customer group not found');
}
if (LOCKED_NAMES.has(current.name) && cleaned !== current.name) {
throw new Error('Name cannot be changed');
}
const result = await pool
.request()
.input('id', id)
.input('companyId', companyId)
.input('name', cleaned)
.query(UPDATE_NAME);
.query(UPDATE);
const row = result.recordset[0];
if (!row) {
throw new Error('Customer group not found');
}
return { id: row.id, name: row.name };
return shape(row);
};
}

View File

@@ -1,6 +1,7 @@
import {
CREATE,
GET_BY_COMPANY_AND_NAME,
GET_NEXT_SORT,
} from '../../sql/customergroup.js';
import { SUPPLIER_GROUP_NAME } from '../../sql/entity.js';
@@ -17,9 +18,15 @@ export default async function ensureLieferantenGroup(pool, companyId, tx) {
return existing.recordset[0].id;
}
const next = await req()
.input('companyId', companyId)
.query(GET_NEXT_SORT);
const sort = next.recordset[0].sort;
const created = await req()
.input('companyId', companyId)
.input('name', SUPPLIER_GROUP_NAME)
.input('sort', sort)
.query(CREATE);
return created.recordset[0].id;