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

@@ -193,12 +193,15 @@ Company-scoped customer groups. Auth via `resolveCompanyAccess`.
| Method | Signature | Returns |
| --- | --- | --- |
| `list` | `(googleAuthId, companyId)` | `{ id, name }[]` |
| `create` | `(googleAuthId, companyId, { name })` | `{ id, name }` |
| `update` | `(googleAuthId, companyId, id, { name })` | `{ id, name }` |
| `list` | `(googleAuthId, companyId)` | `{ id, name, sort }[]` |
| `create` | `(googleAuthId, companyId, { name })` | `{ id, name, sort }` |
| `update` | `(googleAuthId, companyId, id, { name })` | `{ id, name, sort }` |
| `order` | `(googleAuthId, companyId, { customergroupIds })` | `{ customergroupIds }` |
| `delete` | `(googleAuthId, companyId, id)` | `void` |
Empty name → `Name required`. Deleting the POS group (or any group still referenced with `NO ACTION`) → `Customer group in use`. Cascades entities when the group is deleted. Errors: `Company not found`, `Not authorized`, `Name required`, `Customer group not found`, `Customer group in use`.
List ordered by `sort`, `name`, `id`. `create` appends with next `sort` (`0…n`). `order` sets `sort` to each id’s index `0…n-1`; `customergroupIds` must be exactly the company’s groups (same set, no dups) else `Invalid customer group order`. Implicit groups `POS` (company create) and `Lieferanten` (first supplier) cannot be renamed (`Name cannot be changed`). Creating a group with those reserved names → `Name not allowed`.
Empty name → `Name required`. Deleting the POS group (or any group still referenced with `NO ACTION`) → `Customer group in use`. Cascades entities when the group is deleted. Errors: `Company not found`, `Not authorized`, `Name required`, `Name not allowed`, `Name cannot be changed`, `Invalid customer group order`, `Customer group not found`, `Customer group in use`.
---
@@ -682,6 +685,7 @@ res.set('Content-Type', thumb.contentType).send(thumb.buffer);
| `GET /api/customergroup` | `customergroup.list(googleAuthId, companyId)` |
| `POST /api/customergroup` | `customergroup.create(googleAuthId, companyId, { name })` |
| `PATCH /api/customergroup/:id` | `customergroup.update(googleAuthId, companyId, id, { name })` |
| `PUT /api/customergroup/order` | `customergroup.order(googleAuthId, companyId, { customergroupIds })` |
| `DELETE /api/customergroup/:id` | `customergroup.delete(googleAuthId, companyId, id)` |
| `GET /api/entity` | `entity.list(googleAuthId, companyId, { customergroupId?, role? })` |
| `GET /api/entity/:id` | `entity.getById(googleAuthId, companyId, id)` |

View File

@@ -9,7 +9,7 @@ digraph schema {
label="Identity & Company"; labeljust="l"; fontsize=15;
style="rounded,filled"; fillcolor="#ffffff60"; color="#a0aec0";
fontcolor="#1a365d"; margin=14;
customergroup [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="#a0aec0"><TR><TD COLSPAN="2" BGCOLOR="#bee3f8" ALIGN="CENTER" CELLPADDING="1" BORDER="1"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD PORT="in0" BGCOLOR="#bee3f8" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD><TD PORT="in1" BGCOLOR="#bee3f8" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD><TD PORT="in2" BGCOLOR="#bee3f8" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD></TR><TR><TD COLSPAN="3" ALIGN="CENTER" CELLPADDING="3"><FONT COLOR="#1a365d" POINT-SIZE="12"><B>customergroup</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>id</B></FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>PK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="company_id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>company_id</B>?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>FK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="name" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">name</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR></TABLE>>];
customergroup [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="#a0aec0"><TR><TD COLSPAN="2" BGCOLOR="#bee3f8" ALIGN="CENTER" CELLPADDING="1" BORDER="1"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD PORT="in0" BGCOLOR="#bee3f8" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD><TD PORT="in1" BGCOLOR="#bee3f8" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD><TD PORT="in2" BGCOLOR="#bee3f8" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD></TR><TR><TD COLSPAN="3" ALIGN="CENTER" CELLPADDING="3"><FONT COLOR="#1a365d" POINT-SIZE="12"><B>customergroup</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>id</B></FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>PK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="company_id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>company_id</B>?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>FK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="name" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">name</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="sort" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">sort</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT</FONT></TD></TR></TABLE></TD></TR></TABLE>>];
meta [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="#a0aec0"><TR><TD COLSPAN="2" BGCOLOR="#bee3f8" ALIGN="CENTER" CELLPADDING="4" BORDER="1"><FONT COLOR="#1a365d" POINT-SIZE="12"><B>meta</B></FONT></TD></TR><TR><TD PORT="key" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">key</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="value" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">value</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(MAX)</FONT></TD></TR></TABLE></TD></TR></TABLE>>];
numbers [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="#a0aec0"><TR><TD COLSPAN="2" BGCOLOR="#bee3f8" ALIGN="CENTER" CELLPADDING="4" BORDER="1"><FONT COLOR="#1a365d" POINT-SIZE="12"><B>numbers</B></FONT></TD></TR><TR><TD PORT="id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>id</B></FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>PK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="company_id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>company_id</B></FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>FK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="type" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">type</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(100)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="prefix" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">prefix</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(50)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="padding" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">padding</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="next" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">next</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT</FONT></TD></TR></TABLE></TD></TR></TABLE>>];
entityaddress [label=<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="#a0aec0"><TR><TD COLSPAN="2" BGCOLOR="#bee3f8" ALIGN="CENTER" CELLPADDING="4" BORDER="1"><FONT COLOR="#1a365d" POINT-SIZE="12"><B>entityaddress</B></FONT></TD></TR><TR><TD PORT="id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>id</B></FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>PK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="entity_id" COLSPAN="2" BGCOLOR="#ebf8ff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748"><B>entity_id</B></FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT <B>FK</B></FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="first_name" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">first_name?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="name" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">name?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="street" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">street?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="streetnumber" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">streetnumber?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(50)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="zip" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">zip?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(50)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="city" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">city?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="county" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">county?</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(255)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="addresstype" COLSPAN="2" BGCOLOR="#f7fafc" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">addresstype</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">NVARCHAR(50)</FONT></TD></TR></TABLE></TD></TR><TR><TD PORT="sort" COLSPAN="2" BGCOLOR="#ffffff" BORDER="1" CELLPADDING="0"><TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE"><TR><TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="#2d3748">sort</FONT></TD><TD ALIGN="RIGHT" CELLPADDING="4"><FONT COLOR="#718096" POINT-SIZE="9">INT</FONT></TD></TR></TABLE></TD></TR></TABLE>>];

View File

@@ -42,27 +42,31 @@
<!-- customergroup -->
<g id="node1" class="node">
<title>customergroup</title>
<polygon fill="#bee3f8" stroke="none" points="527.76,-644.8 527.76,-673.2 664.24,-673.2 664.24,-644.8 527.76,-644.8"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-644.8 527.76,-673.2 664.24,-673.2 664.24,-644.8 527.76,-644.8"/>
<polygon fill="#bee3f8" stroke="none" points="529.76,-667.2 529.76,-671.2 573.92,-671.2 573.92,-667.2 529.76,-667.2"/>
<polygon fill="#bee3f8" stroke="none" points="573.92,-667.2 573.92,-671.2 618.08,-671.2 618.08,-667.2 573.92,-667.2"/>
<polygon fill="#bee3f8" stroke="none" points="618.08,-667.2 618.08,-671.2 662.24,-671.2 662.24,-667.2 618.08,-667.2"/>
<text xml:space="preserve" text-anchor="start" x="552" y="-654.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#1a365d">customergroup</text>
<polygon fill="#ebf8ff" stroke="none" points="527.76,-618 527.76,-644.8 664.24,-644.8 664.24,-618 527.76,-618"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-618 527.76,-644.8 664.24,-644.8 664.24,-618 527.76,-618"/>
<text xml:space="preserve" text-anchor="start" x="532.76" y="-628.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#2d3748">id</text>
<text xml:space="preserve" text-anchor="start" x="629.73" y="-629.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#718096">INT </text>
<text xml:space="preserve" text-anchor="start" x="646.73" y="-629.7" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#718096">PK</text>
<polygon fill="#ebf8ff" stroke="none" points="527.76,-591.2 527.76,-618 664.24,-618 664.24,-591.2 527.76,-591.2"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-591.2 527.76,-618 664.24,-618 664.24,-591.2 527.76,-591.2"/>
<text xml:space="preserve" text-anchor="start" x="532.76" y="-601.4" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#2d3748">company_id</text>
<text xml:space="preserve" text-anchor="start" x="614.45" y="-601.4" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#2d3748">?</text>
<text xml:space="preserve" text-anchor="start" x="630.24" y="-602.9" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#718096">INT </text>
<text xml:space="preserve" text-anchor="start" x="647.24" y="-602.9" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#718096">FK</text>
<polygon fill="#ffffff" stroke="none" points="527.76,-564.4 527.76,-591.2 664.24,-591.2 664.24,-564.4 527.76,-564.4"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-564.4 527.76,-591.2 664.24,-591.2 664.24,-564.4 527.76,-564.4"/>
<text xml:space="preserve" text-anchor="start" x="532.76" y="-572.2" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#2d3748">name</text>
<text xml:space="preserve" text-anchor="start" x="587.72" y="-574.2" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#718096">NVARCHAR(255)</text>
<polygon fill="#bee3f8" stroke="none" points="527.76,-658.2 527.76,-686.6 664.24,-686.6 664.24,-658.2 527.76,-658.2"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-658.2 527.76,-686.6 664.24,-686.6 664.24,-658.2 527.76,-658.2"/>
<polygon fill="#bee3f8" stroke="none" points="529.76,-680.6 529.76,-684.6 573.92,-684.6 573.92,-680.6 529.76,-680.6"/>
<polygon fill="#bee3f8" stroke="none" points="573.92,-680.6 573.92,-684.6 618.08,-684.6 618.08,-680.6 573.92,-680.6"/>
<polygon fill="#bee3f8" stroke="none" points="618.08,-680.6 618.08,-684.6 662.24,-684.6 662.24,-680.6 618.08,-680.6"/>
<text xml:space="preserve" text-anchor="start" x="552" y="-667.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#1a365d">customergroup</text>
<polygon fill="#ebf8ff" stroke="none" points="527.76,-631.4 527.76,-658.2 664.24,-658.2 664.24,-631.4 527.76,-631.4"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-631.4 527.76,-658.2 664.24,-658.2 664.24,-631.4 527.76,-631.4"/>
<text xml:space="preserve" text-anchor="start" x="532.76" y="-641.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#2d3748">id</text>
<text xml:space="preserve" text-anchor="start" x="629.73" y="-643.1" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#718096">INT </text>
<text xml:space="preserve" text-anchor="start" x="646.73" y="-643.1" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#718096">PK</text>
<polygon fill="#ebf8ff" stroke="none" points="527.76,-604.6 527.76,-631.4 664.24,-631.4 664.24,-604.6 527.76,-604.6"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-604.6 527.76,-631.4 664.24,-631.4 664.24,-604.6 527.76,-604.6"/>
<text xml:space="preserve" text-anchor="start" x="532.76" y="-614.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="14.00" fill="#2d3748">company_id</text>
<text xml:space="preserve" text-anchor="start" x="614.45" y="-614.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#2d3748">?</text>
<text xml:space="preserve" text-anchor="start" x="630.24" y="-616.3" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#718096">INT </text>
<text xml:space="preserve" text-anchor="start" x="647.24" y="-616.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="9.00" fill="#718096">FK</text>
<polygon fill="#ffffff" stroke="none" points="527.76,-577.8 527.76,-604.6 664.24,-604.6 664.24,-577.8 527.76,-577.8"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-577.8 527.76,-604.6 664.24,-604.6 664.24,-577.8 527.76,-577.8"/>
<text xml:space="preserve" text-anchor="start" x="532.76" y="-585.6" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#2d3748">name</text>
<text xml:space="preserve" text-anchor="start" x="587.72" y="-587.6" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#718096">NVARCHAR(255)</text>
<polygon fill="#f7fafc" stroke="none" points="527.76,-551 527.76,-577.8 664.24,-577.8 664.24,-551 527.76,-551"/>
<polygon fill="none" stroke="#a0aec0" points="527.76,-551 527.76,-577.8 664.24,-577.8 664.24,-551 527.76,-551"/>
<text xml:space="preserve" text-anchor="start" x="532.76" y="-558.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#2d3748">sort</text>
<text xml:space="preserve" text-anchor="start" x="644.74" y="-560.8" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#718096">INT</text>
</g>
<!-- company -->
<g id="node8" class="node">
@@ -129,8 +133,8 @@
<g id="edge8" class="edge">
<title>customergroup:w&#45;&gt;company:n</title>
<g id="a_edge8"><a xlink:title="FK_customergroup_company">
<path fill="none" stroke="#97266d" stroke-width="1.4" d="M526.76,-604.6C507.12,-604.6 515.85,-576.03 527.76,-560.4 578.18,-494.28 625.72,-524.34 707,-506.8 828.73,-480.53 1254.71,-553.72 1275.44,-438.79"/>
<polygon fill="#97266d" stroke="#97266d" stroke-width="1.4" points="1277.87,-439.1 1276.04,-431.91 1272.99,-438.67 1277.87,-439.1"/>
<path fill="none" stroke="#97266d" stroke-width="1.4" d="M526.76,-618C510.99,-618 517.82,-559.25 527.76,-547 579.23,-483.63 626.64,-521.22 707,-506.8 767,-496.03 1231.62,-493.81 1273.24,-438.03"/>
<polygon fill="#97266d" stroke="#97266d" stroke-width="1.4" points="1275.42,-439.21 1275.5,-431.79 1270.82,-437.54 1275.42,-439.21"/>
</a>
</g>
</g>
@@ -294,8 +298,8 @@
<g id="edge10" class="edge">
<title>entity:nw&#45;&gt;customergroup:n</title>
<g id="a_edge10"><a xlink:title="FK_entity_customergroup">
<path fill="none" stroke="#276749" stroke-width="1.4" d="M718.05,-1034.6C718.05,-1034.6 617.38,-735.68 598.9,-680.8"/>
<polygon fill="#276749" stroke="#276749" stroke-width="1.4" points="601.23,-680.06 596.68,-674.21 596.59,-681.62 601.23,-680.06"/>
<path fill="none" stroke="#276749" stroke-width="1.4" d="M718.05,-1034.6C718.05,-1034.6 617.38,-746.73 598.9,-693.88"/>
<polygon fill="#276749" stroke="#276749" stroke-width="1.4" points="601.32,-693.4 596.7,-687.6 596.7,-695.02 601.32,-693.4"/>
</a>
</g>
</g>
@@ -422,8 +426,8 @@
<g id="edge9" class="edge">
<title>company:w&#45;&gt;customergroup:n</title>
<g id="a_edge9"><a xlink:title="FK_company_poscustomergroup">
<path fill="none" stroke="#4c51bf" stroke-width="1.4" d="M1244.61,-281.8C985.59,-281.8 872.16,-307.27 707,-506.8 659.38,-564.33 729.51,-623.27 672.24,-671.2 664.54,-677.64 651.81,-682.57 644.93,-679.97"/>
<polygon fill="#4c51bf" stroke="#4c51bf" stroke-width="1.4" points="647.02,-678.69 641.27,-674.01 642.84,-681.25 647.02,-678.69"/>
<path fill="none" stroke="#4c51bf" stroke-width="1.4" d="M1244.61,-281.8C985.59,-281.8 870.62,-306.01 707,-506.8 656.14,-569.22 733.59,-632.46 672.24,-684.6 664.42,-691.24 651.27,-696.22 644.49,-693.2"/>
<polygon fill="#4c51bf" stroke="#4c51bf" stroke-width="1.4" points="646.8,-692.31 641.21,-687.44 642.54,-694.74 646.8,-692.31"/>
</a>
</g>
</g>
@@ -1676,8 +1680,8 @@
<g id="edge45" class="edge">
<title>price:se&#45;&gt;customergroup:n</title>
<g id="a_edge45"><a xlink:title="FK_price_customergroup">
<path fill="none" stroke="#dd6b20" stroke-width="1.4" d="M476.95,-1413C489.04,-1400.91 487.42,-1276.62 489,-1259.6 512.98,-1001.1 550.98,-936.21 551.83,-680.97"/>
<polygon fill="#dd6b20" stroke="#dd6b20" stroke-width="1.4" points="554.28,-681.32 551.84,-674.32 549.38,-681.31 554.28,-681.32"/>
<path fill="none" stroke="#dd6b20" stroke-width="1.4" d="M476.95,-1413C489.04,-1400.91 487.4,-1276.62 489,-1259.6 512.74,-1007.09 550.94,-943.71 551.83,-694.54"/>
<polygon fill="#dd6b20" stroke="#dd6b20" stroke-width="1.4" points="554.28,-694.72 551.84,-687.72 549.38,-694.71 554.28,-694.72"/>
</a>
</g>
</g>

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

View File

@@ -164,6 +164,7 @@ const MENU = {
{ value: 'customergroup.list', label: 'list', hint: 'googleAuthId, companyId → rows' },
{ value: 'customergroup.create', label: 'create', hint: 'googleAuthId, companyId, name → row' },
{ value: 'customergroup.update', label: 'update', hint: 'googleAuthId, companyId, id, name → row' },
{ value: 'customergroup.order', label: 'order', hint: 'googleAuthId, companyId, JSON customergroupIds → { customergroupIds }' },
{ value: 'customergroup.delete', label: 'delete', hint: 'googleAuthId, companyId, id' },
],
},
@@ -799,9 +800,11 @@ async function runMethod(client, action) {
if (name == null) return;
return [
'result',
await client.customergroup.create(Number(googleAuthId), Number(companyId), {
name,
}),
await client.customergroup.create(
Number(googleAuthId),
Number(companyId),
{ name },
),
];
}
case 'customergroup.update': {
@@ -825,6 +828,33 @@ async function runMethod(client, action) {
),
];
}
case 'customergroup.order': {
const googleAuthId = await askInt('google_auth id');
if (googleAuthId == null) return;
const companyId = await askInt('company id');
if (companyId == null) return;
const idsJson = await askText('customergroupIds JSON', {
placeholder: '[3,1,2]',
validate: (v) => {
if (!v?.trim()) return 'Required';
try {
const parsed = JSON.parse(v);
if (!Array.isArray(parsed)) return 'Must be a JSON array';
} catch {
return 'Invalid JSON';
}
},
});
if (idsJson == null) return;
return [
'result',
await client.customergroup.order(
Number(googleAuthId),
Number(companyId),
{ customergroupIds: JSON.parse(idsJson) },
),
];
}
case 'customergroup.delete': {
const googleAuthId = await askInt('google_auth id');
if (googleAuthId == null) return;

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;