Add ledger, address/contact sort, and tighter schema-graph layout.

Replace manifestarticle.transactedamount with an append-only ledger, add sort on entity address/contact, and allow same-rank FKs with crossing-aware exits in the graph.
This commit is contained in:
seb
2026-08-06 07:39:48 +02:00
parent 3b77a1c946
commit ae549fd022
17 changed files with 2019 additions and 1656 deletions

View File

@@ -236,11 +236,11 @@ Addresses for an entity. Auth via company + entity ownership.
| Method | Signature | Returns |
| --- | --- | --- |
| `list` | `(googleAuthId, companyId, entityId)` | `EntityAddress[]` |
| `create` | `(googleAuthId, companyId, entityId, { addressType, firstName?, name?, street?, streetNumber?, zip?, city?, county? })` | `EntityAddress` |
| `create` | `(googleAuthId, companyId, entityId, { addressType, firstName?, name?, street?, streetNumber?, zip?, city?, county?, sort? })` | `EntityAddress` |
| `update` | `(googleAuthId, companyId, entityId, id, patch)` | `EntityAddress` |
| `delete` | `(googleAuthId, companyId, entityId, id)` | `void` |
`addressType` required. Errors: `Company not found`, `Not authorized`, `Entity not found`, `Address type required`, `Address not found`.
`addressType` required. `sort` defaults to next index for the entity (`0…n`). List ordered by `sort`, `id`. Errors: `Company not found`, `Not authorized`, `Entity not found`, `Address type required`, `Address not found`, `Invalid sort`.
---
@@ -250,12 +250,12 @@ Contacts for an entity (`contactType` e.g. `email`, `phone`). Auth via company +
| Method | Signature | Returns |
| --- | --- | --- |
| `list` | `(googleAuthId, companyId, entityId)` | `{ id, entityId, contactType, contact }[]` |
| `create` | `(googleAuthId, companyId, entityId, { contactType, contact })` | row |
| `update` | `(googleAuthId, companyId, entityId, id, { contactType?, contact? })` | row |
| `list` | `(googleAuthId, companyId, entityId)` | `{ id, entityId, contactType, contact, sort }[]` |
| `create` | `(googleAuthId, companyId, entityId, { contactType, contact, sort? })` | row |
| `update` | `(googleAuthId, companyId, entityId, id, { contactType?, contact?, sort? })` | row |
| `delete` | `(googleAuthId, companyId, entityId, id)` | `void` |
Errors: `Company not found`, `Not authorized`, `Entity not found`, `Contact required`, `Contact not found`.
`sort` defaults to next index for the entity. List ordered by `sort`, `id`. Errors: `Company not found`, `Not authorized`, `Entity not found`, `Contact required`, `Contact not found`, `Invalid sort`.
---
@@ -301,7 +301,6 @@ type DeliveryLine = {
sku: string | null,
amount: number,
net: number,
transactedAmount: number,
rebate: number,
tax: number, // tax.rate
comment: string | null,
@@ -310,7 +309,7 @@ type DeliveryLine = {
type Delivery = DeliverySummary & { lines: DeliveryLine[] }
```
`create` inserts header + lines in one transaction. `supplierId` null or a Lieferanten `entity` of the company (else `Supplier not found`). Each line requires `sku` (resolved in the company category tree → `Article not found`), `amount`, `net`, `tax` (rate → `tax.ensure`); `rebate` defaults `0`; `transactedAmount` defaults to `amount`. Empty/invalid `number` or `date` → `Invalid delivery`; bad line payload → `Invalid lines`.
`create` inserts header + lines in one transaction. `supplierId` null or a Lieferanten `entity` of the company (else `Supplier not found`). Each line requires `sku` (resolved in the company category tree → `Article not found`), `amount`, `net`, `tax` (rate → `tax.ensure`); `rebate` defaults `0`. Empty/invalid `number` or `date` → `Invalid delivery`; bad line payload → `Invalid lines`.
`update` patches header fields that are present. If `lines` is present, replace-all lines; if omitted, leave lines unchanged. Returns full `Delivery`.
@@ -318,6 +317,23 @@ type Delivery = DeliverySummary & { lines: DeliveryLine[] }
---
## `ledger`
Append-only SQL Server ledger table (`WITH (LEDGER = ON (APPEND_ONLY = ON))`). Rows cannot be updated or deleted. References `manifestarticle` with `ON DELETE NO ACTION` (a line with ledger entries cannot be removed). No store API yet — INSERT only via future store methods / direct SQL.
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `INT IDENTITY` | PK |
| `manifestarticle_id` | `INT` | FK → `manifestarticle` |
| `transaction_type` | `NVARCHAR(100)` | |
| `amount` | `FLOAT` | |
| `transaction_text` | `NVARCHAR(MAX)` | |
| `prev_hash` | `NVARCHAR(128)` | nullable (genesis row) |
Engine also maintains `ledger_start_transaction_id` / `ledger_start_sequence_number`. `purge`/`reset-db` soft-drops the table (`MSSQL_DroppedLedgerTable_*` remains; a new `ledger` can still be created).
---
## `posterminal`
Company-scoped POS terminals. Auth via `resolveCompanyAccess`.

View File

@@ -231,6 +231,62 @@ function edgeKey(edge) {
return `${edge.from}|${edge.to}|${edge.label}|${edge.inferred ? 'inferred' : 'fk'}`;
}
/**
* Only a spanning forest of FK edges constrains Graphviz ranks. Extra FKs
* from the same table get constraint=false so they may connect peers on the
* same level instead of forcing another rank (saves vertical space).
*
* Per source table, pick one "primary" parent for ranking:
* 1. real FK over inferred
* 2. ownership-style parent (parent name is a prefix of the child, e.g.
* manifest → manifestarticle)
* 3. highest in-degree target (hubs like company stay above)
*/
function selectRankingEdgeKeys(edges) {
const ranking = new Set();
const inDegree = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
}
const ownershipScore = (child, parent) => {
const c = child.toLowerCase();
const p = parent.toLowerCase();
if (c.startsWith(p) && c.length > p.length) return p.length;
return 0;
};
const bySource = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const group of bySource.values()) {
group.sort((a, b) => {
const aInferred = a.inferred ? 1 : 0;
const bInferred = b.inferred ? 1 : 0;
if (aInferred !== bInferred) return aInferred - bInferred;
const aOwn = ownershipScore(a.from, a.to);
const bOwn = ownershipScore(b.from, b.to);
if (aOwn !== bOwn) return bOwn - aOwn;
const aDeg = inDegree.get(a.to) ?? 0;
const bDeg = inDegree.get(b.to) ?? 0;
if (aDeg !== bDeg) return bDeg - aDeg;
return a.label.localeCompare(b.label);
});
ranking.add(edgeKey(group[0]));
}
return ranking;
}
function assignEdgeColors(edges) {
const colors = new Map();
let next = 0;
@@ -309,7 +365,7 @@ const CLUSTERS = [
{
id: 'commerce',
label: 'Pricing & Documents',
tables: ['tax', 'price', 'manifest', 'manifestarticle'],
tables: ['tax', 'price', 'manifest', 'manifestarticle', 'ledger'],
},
{ id: 'units', label: 'Units', tables: ['unit'] },
];
@@ -381,14 +437,142 @@ function parsePlainPositions(plain) {
for (const line of plain.split('\n')) {
if (!line.startsWith('node ')) continue;
// node name x y width height ...
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+/);
const match = line.match(
/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/
);
if (!match) continue;
positions.set(match[1], { x: Number(match[2]), y: Number(match[3]) });
positions.set(match[1], {
x: Number(match[2]),
y: Number(match[3]),
width: Number(match[4]),
height: Number(match[5]),
});
}
return positions;
}
function flipCompassLR(compass) {
return (
{ e: 'w', w: 'e', ne: 'nw', nw: 'ne', se: 'sw', sw: 'se' }[compass] ??
compass
);
}
function flipCompassNS(compass) {
return (
{ ne: 'se', se: 'ne', nw: 'sw', sw: 'nw', e: 'e', w: 'w' }[compass] ??
compass
);
}
/**
* Greedy exit/port tweaks against real Graphviz splines. Tries L/R flips,
* N/S diagonal flips, and adjacent head-port swaps; keeps moves that cut
* SVG segment crossings.
*/
function optimizeAnchorsWithLayout(graphviz, tables, edges, positions, anchors) {
const tailCompass = new Map(anchors.tailCompass);
const headPort = new Map(anchors.headPort);
const inboundCounts = anchors.inboundCounts;
let current = { headPort, tailCompass, inboundCounts };
const measure = () => {
const dot = buildDot(tables, edges, positions, current);
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot'));
return { svg, crossings: countSvgCrossings(svg), dot };
};
let { crossings: best, svg: bestSvg, dot: bestDot } = measure();
const acceptIfBetter = (trial) => {
if (trial.crossings < best) {
best = trial.crossings;
bestSvg = trial.svg;
bestDot = trial.dot;
return true;
}
return false;
};
const trySeed = (seedCompass) => {
const saved = new Map(tailCompass);
for (const [k, v] of seedCompass) tailCompass.set(k, v);
current = { headPort, tailCompass, inboundCounts };
const trial = measure();
if (!acceptIfBetter(trial)) {
for (const [k, v] of saved) tailCompass.set(k, v);
current = { headPort, tailCompass, inboundCounts };
}
};
// Also try the globally mirrored L/R assignment as an alternate seed.
trySeed(
new Map([...tailCompass.entries()].map(([k, v]) => [k, flipCompassLR(v)]))
);
const byTarget = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
byTarget.get(edge.to).push(edge);
}
let improved = true;
for (let sweep = 0; sweep < 4 && improved; sweep++) {
improved = false;
for (const edge of edges) {
if (edge.from === edge.to) continue;
const key = edgeKey(edge);
const prev = tailCompass.get(key);
if (!prev) continue;
for (const flip of [flipCompassLR, flipCompassNS]) {
const next = flip(prev);
if (next === prev) continue;
tailCompass.set(key, next);
current = { headPort, tailCompass, inboundCounts };
const trial = measure();
if (acceptIfBetter(trial)) {
improved = true;
break;
}
tailCompass.set(key, prev);
current = { headPort, tailCompass, inboundCounts };
}
}
for (const group of byTarget.values()) {
const keys = group
.map((e) => edgeKey(e))
.sort(
(a, b) =>
Number(String(headPort.get(a)).replace(/^in/, '')) -
Number(String(headPort.get(b)).replace(/^in/, ''))
);
for (let i = 0; i < keys.length - 1; i++) {
const a = headPort.get(keys[i]);
const b = headPort.get(keys[i + 1]);
if (!a || !b || a === b) continue;
headPort.set(keys[i], b);
headPort.set(keys[i + 1], a);
current = { headPort, tailCompass, inboundCounts };
const trial = measure();
if (acceptIfBetter(trial)) {
improved = true;
} else {
headPort.set(keys[i], a);
headPort.set(keys[i + 1], b);
current = { headPort, tailCompass, inboundCounts };
}
}
}
}
return { anchors: current, crossings: best, svg: bestSvg, dot: bestDot };
}
/**
* Sort cluster members by their first-pass x so Graphviz's crossing
* minimizer starts from a better initial order within each cluster.
@@ -468,7 +652,8 @@ function buildTableLabel(table, inboundCount = 0) {
* Head: unique in* ports on the target title bar, ordered by source x.
* Tail: FK column port, always leaving sideways or diagonally
* (e/w/ne/nw/se/sw) — never straight up/down, which stacks stubs on the
* row edge.
* row edge. Exit side aims at the head-port x (not just the target center)
* so left-header arrivals leave west and right-header arrivals leave east.
*/
function assignEdgeAnchors(edges, positions) {
const headPort = new Map();
@@ -516,10 +701,24 @@ function assignEdgeAnchors(edges, positions) {
return;
}
const fromX = positions?.get(edge.from)?.x ?? 0;
const toX = positions?.get(edge.to)?.x ?? fromX;
const fromY = positions?.get(edge.from)?.y ?? 0;
const toY = positions?.get(edge.to)?.y ?? fromY;
const from = positions?.get(edge.from);
const to = positions?.get(edge.to);
const fromX = from?.x ?? 0;
const fromY = from?.y ?? 0;
const toY = to?.y ?? fromY;
// Aim at the specific header port, not the node center — otherwise
// edges to the left/right of a wide header pick the wrong exit side.
const key = edgeKey(edge);
const hp = headPort.get(key) ?? 'in0';
const hi = Number(String(hp).replace(/^in/, '')) || 0;
const nIn = Math.max(1, inboundCounts.get(edge.to) ?? 1);
const toW = to?.width ?? 1;
const toX =
to != null
? to.x - toW / 2 + ((hi + 0.5) / nIn) * toW
: fromX;
const dx = toX - fromX;
const dy = toY - fromY;
const absDx = Math.abs(dx);
@@ -560,7 +759,7 @@ function assignEdgeAnchors(edges, positions) {
: 'nw';
}
tailCompass.set(edgeKey(edge), compass);
tailCompass.set(key, compass);
});
}
@@ -592,18 +791,17 @@ function buildLegendLabel() {
);
}
function buildDot(tables, edges, positions = null) {
function buildDot(tables, edges, positions = null, anchors = null) {
const edgeColors = assignEdgeColors(edges);
const { headPort, tailCompass, inboundCounts } = assignEdgeAnchors(
edges,
positions
);
const rankingEdgeKeys = selectRankingEdgeKeys(edges);
const { headPort, tailCompass, inboundCounts } =
anchors ?? assignEdgeAnchors(edges, positions);
const lines = [
'digraph schema {',
// TB = landscape for this schema (many tables per rank spread horizontally).
// mclimit bumps Graphviz's own crossing-minimization iterations.
` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`,
' nodesep=0.55, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' compound=true, newrank=true, mclimit=4.0];',
' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
@@ -673,12 +871,21 @@ function buildDot(tables, edges, positions = null) {
// of those rows (e→e). Node-level ne→nw looked above the table but
// detached from the actual columns; column→header with :ne still dipped SE.
if (edge.from === edge.to) {
attrs.push('constraint=false');
lines.push(
` ${edge.from}:${sourcePort}:e -> ${edge.to}:id:e [${attrs.join(', ')}];`
);
continue;
}
// Non-primary FKs may share a rank with their target (lateral links).
if (!rankingEdgeKeys.has(key)) {
attrs.push('constraint=false', 'weight=1');
} else {
// Prefer ranking edges in crossing minimization / length tradeoffs.
attrs.push('weight=3');
}
lines.push(
` ${edge.from}:${sourcePort}:${tail} -> ${edge.to}:${targetPort}:n [${attrs.join(', ')}];`
);
@@ -692,14 +899,16 @@ function countSvgCrossings(svg) {
const paths = [
...svg.matchAll(/<g id="edge\d+"[\s\S]*?<path[^>]*d="([^"]+)"/g),
].map((m) => m[1]);
const segs = [];
const edgeSegs = [];
for (const d of paths) {
const nums = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
]);
const segs = [];
for (let i = 0; i < nums.length - 1; i++) segs.push([nums[i], nums[i + 1]]);
edgeSegs.push(segs);
}
const orient = (a, b, c) =>
@@ -713,19 +922,26 @@ function countSvgCrossings(svg) {
};
let n = 0;
for (let i = 0; i < segs.length; i++) {
for (let j = i + 1; j < segs.length; j++) {
const [a, b] = segs[i];
const [c, d] = segs[j];
if (
(a[0] === c[0] && a[1] === c[1]) ||
(a[0] === d[0] && a[1] === d[1]) ||
(b[0] === c[0] && b[1] === c[1]) ||
(b[0] === d[0] && b[1] === d[1])
) {
continue;
for (let e1 = 0; e1 < edgeSegs.length; e1++) {
for (let e2 = e1 + 1; e2 < edgeSegs.length; e2++) {
let pairCrosses = false;
outer: for (const [a, b] of edgeSegs[e1]) {
for (const [c, d] of edgeSegs[e2]) {
if (
(a[0] === c[0] && a[1] === c[1]) ||
(a[0] === d[0] && a[1] === d[1]) ||
(b[0] === c[0] && b[1] === c[1]) ||
(b[0] === d[0] && b[1] === d[1])
) {
continue;
}
if (crosses(a, b, c, d)) {
pairCrosses = true;
break outer;
}
}
}
if (crosses(a, b, c, d)) n++;
if (pairCrosses) n++;
}
}
@@ -805,30 +1021,35 @@ if (unclustered.length > 0) {
const graphviz = await Graphviz.load();
// Two layout passes: learn positions, then reorder columns/cluster members
// (barycenter) so FK ports and neighbor tables line up and crossings drop.
// Layout passes: learn positions, reorder columns/cluster members (barycenter).
let positions = null;
for (let pass = 0; pass < 2; pass++) {
for (let pass = 0; pass < 3; pass++) {
const probeDot = buildDot(tables, edges, positions);
const plain = graphviz.layout(probeDot, 'plain', 'dot');
positions = parsePlainPositions(plain);
reorderColumnsForCrossingReduction(tables, edges, positions);
}
const dot = buildDot(tables, edges, positions);
writeFileSync(dotOutputPath, dot, 'utf8');
// Hill-climb L/R exits against real spline crossings.
const baseAnchors = assignEdgeAnchors(edges, positions);
const optimized = optimizeAnchorsWithLayout(
graphviz,
tables,
edges,
positions,
baseAnchors
);
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot'));
writeFileSync(outputPath, svg, 'utf8');
writeFileSync(dotOutputPath, optimized.dot, 'utf8');
writeFileSync(outputPath, optimized.svg, 'utf8');
const dim = svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
const crossings = countSvgCrossings(svg);
const dim = optimized.svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
console.log(`Parsed ${tables.size} tables and ${edges.length} foreign keys`);
console.log(`Written ${dotOutputPath}`);
console.log(`Written ${outputPath}`);
if (dim) {
console.log(
`Layout ${dim[1]}pt × ${dim[2]}pt · ~${crossings} segment crossings`
`Layout ${dim[1]}pt × ${dim[2]}pt · ~${optimized.crossings} edge-pair crossings`
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 186 KiB

View File

@@ -178,6 +178,7 @@ BEGIN
city NVARCHAR(255) NULL,
county NVARCHAR(255) NULL,
addresstype NVARCHAR(50) NOT NULL,
sort INT NOT NULL CONSTRAINT DF_entityaddress_sort DEFAULT 0,
CONSTRAINT FK_entityaddress_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE
);
END
@@ -191,6 +192,7 @@ BEGIN
entity_id INT NOT NULL,
contacttype NVARCHAR(50) NOT NULL,
contact NVARCHAR(255) NOT NULL,
sort INT NOT NULL CONSTRAINT DF_entitycontact_sort DEFAULT 0,
CONSTRAINT FK_entitycontact_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE
);
END
@@ -520,7 +522,6 @@ BEGIN
article_id INT NULL,
amount FLOAT NOT NULL,
net FLOAT NOT NULL,
transactedamount FLOAT NOT NULL,
comment NVARCHAR(MAX) NULL,
rebate FLOAT NOT NULL CONSTRAINT DF_manifestarticle_rebate DEFAULT 0,
tax_id INT NOT NULL,
@@ -531,6 +532,21 @@ BEGIN
END
`;
export const INIT_LEDGER = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ledger')
BEGIN
CREATE TABLE ledger (
id INT IDENTITY(1,1) PRIMARY KEY,
manifestarticle_id INT NOT NULL,
transaction_type NVARCHAR(100) NOT NULL,
amount FLOAT NOT NULL,
transaction_text NVARCHAR(MAX) NOT NULL,
prev_hash NVARCHAR(128) NULL,
CONSTRAINT FK_ledger_manifestarticle FOREIGN KEY (manifestarticle_id) REFERENCES manifestarticle(id) ON DELETE NO ACTION
) WITH (LEDGER = ON (APPEND_ONLY = ON));
END
`;
export const INIT_NUMBERS = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'numbers')
BEGIN
@@ -688,6 +704,7 @@ export const SCHEMA_SCRIPTS = [
INIT_PRICE,
INIT_MANIFEST,
INIT_MANIFESTARTICLE,
INIT_LEDGER,
INIT_NUMBERS,
INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_COMPANYUSER,

View File

@@ -33,7 +33,6 @@ SELECT
a.sku,
ma.amount,
ma.net,
ma.transactedamount AS transactedAmount,
ma.rebate,
t.rate AS tax,
ma.comment
@@ -79,7 +78,6 @@ INSERT INTO manifestarticle (
article_id,
amount,
net,
transactedamount,
comment,
rebate,
tax_id
@@ -90,7 +88,6 @@ VALUES (
@articleId,
@amount,
@net,
@transactedAmount,
@comment,
@rebate,
@taxId

View File

@@ -8,7 +8,8 @@ export const ADDRESS_COLUMNS = `
zip,
city,
county,
addresstype AS addressType
addresstype AS addressType,
sort
`;
export const LIST_BY_ENTITY = `
@@ -16,7 +17,7 @@ SELECT
${ADDRESS_COLUMNS}
FROM entityaddress
WHERE entity_id = @entityId
ORDER BY id
ORDER BY sort ASC, id ASC
`;
export const GET_BY_ENTITY_AND_ID = `
@@ -26,6 +27,12 @@ FROM entityaddress
WHERE id = @id AND entity_id = @entityId
`;
export const GET_NEXT_SORT = `
SELECT ISNULL(MAX(sort), -1) + 1 AS sort
FROM entityaddress
WHERE entity_id = @entityId
`;
export const CREATE = `
INSERT INTO entityaddress (
entity_id,
@@ -36,7 +43,8 @@ INSERT INTO entityaddress (
zip,
city,
county,
addresstype
addresstype,
sort
)
OUTPUT
INSERTED.id,
@@ -48,7 +56,8 @@ OUTPUT
INSERTED.zip,
INSERTED.city,
INSERTED.county,
INSERTED.addresstype AS addressType
INSERTED.addresstype AS addressType,
INSERTED.sort
VALUES (
@entityId,
@firstName,
@@ -58,7 +67,8 @@ VALUES (
@zip,
@city,
@county,
@addressType
@addressType,
@sort
)
`;
@@ -72,7 +82,8 @@ SET
zip = @zip,
city = @city,
county = @county,
addresstype = @addressType
addresstype = @addressType,
sort = @sort
OUTPUT
INSERTED.id,
INSERTED.entity_id AS entityId,
@@ -83,7 +94,8 @@ OUTPUT
INSERTED.zip,
INSERTED.city,
INSERTED.county,
INSERTED.addresstype AS addressType
INSERTED.addresstype AS addressType,
INSERTED.sort
WHERE id = @id AND entity_id = @entityId
`;

View File

@@ -2,7 +2,8 @@ export const CONTACT_COLUMNS = `
id,
entity_id AS entityId,
contacttype AS contactType,
contact
contact,
sort
`;
export const LIST_BY_ENTITY = `
@@ -10,7 +11,7 @@ SELECT
${CONTACT_COLUMNS}
FROM entitycontact
WHERE entity_id = @entityId
ORDER BY id
ORDER BY sort ASC, id ASC
`;
export const GET_BY_ENTITY_AND_ID = `
@@ -20,24 +21,32 @@ FROM entitycontact
WHERE id = @id AND entity_id = @entityId
`;
export const GET_NEXT_SORT = `
SELECT ISNULL(MAX(sort), -1) + 1 AS sort
FROM entitycontact
WHERE entity_id = @entityId
`;
export const CREATE = `
INSERT INTO entitycontact (entity_id, contacttype, contact)
INSERT INTO entitycontact (entity_id, contacttype, contact, sort)
OUTPUT
INSERTED.id,
INSERTED.entity_id AS entityId,
INSERTED.contacttype AS contactType,
INSERTED.contact
VALUES (@entityId, @contactType, @contact)
INSERTED.contact,
INSERTED.sort
VALUES (@entityId, @contactType, @contact, @sort)
`;
export const UPDATE = `
UPDATE entitycontact
SET contacttype = @contactType, contact = @contact
SET contacttype = @contactType, contact = @contact, sort = @sort
OUTPUT
INSERTED.id,
INSERTED.entity_id AS entityId,
INSERTED.contacttype AS contactType,
INSERTED.contact
INSERTED.contact,
INSERTED.sort
WHERE id = @id AND entity_id = @entityId
`;

View File

@@ -6,6 +6,7 @@ export const TABLES = [
'posterminalcategory',
'posterminal',
'numbers',
'ledger',
'manifestarticle',
'manifest',
'price',

View File

@@ -22,7 +22,6 @@ export function shapeLine(row) {
sku: row.sku ?? null,
amount: row.amount,
net: row.net,
transactedAmount: row.transactedAmount,
rebate: row.rebate,
tax: row.tax,
comment: row.comment ?? null,
@@ -80,10 +79,6 @@ export function normalizeLines(lines) {
const rebate =
entry?.rebate === undefined || entry?.rebate === null ? 0 : entry.rebate;
const tax = entry?.tax;
const transactedAmount =
entry?.transactedAmount === undefined || entry?.transactedAmount === null
? amount
: entry.transactedAmount;
const comment =
entry?.comment === undefined || entry?.comment === null
? null
@@ -93,12 +88,11 @@ export function normalizeLines(lines) {
!Number.isFinite(amount) ||
!Number.isFinite(net) ||
!Number.isFinite(rebate) ||
!Number.isFinite(tax) ||
!Number.isFinite(transactedAmount)
!Number.isFinite(tax)
) {
throw new Error('Invalid lines');
}
return { sku, amount, net, rebate, tax, transactedAmount, comment };
return { sku, amount, net, rebate, tax, comment };
});
}

View File

@@ -57,7 +57,6 @@ export async function insertLines(
.input('articleId', articleId)
.input('amount', line.amount)
.input('net', line.net)
.input('transactedAmount', line.transactedAmount)
.input('comment', line.comment)
.input('rebate', line.rebate)
.input('taxId', taxId)

View File

@@ -1,4 +1,4 @@
import { CREATE } from '../../sql/entityaddress.js';
import { CREATE, GET_NEXT_SORT } from '../../sql/entityaddress.js';
import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
@@ -14,6 +14,7 @@ function shape(row) {
city: row.city ?? null,
county: row.county ?? null,
addressType: row.addressType,
sort: row.sort,
};
}
@@ -38,6 +39,17 @@ export default function create(pool) {
await access(googleAuthId, companyId);
await entityAccess(companyId, entityId);
let sort = input.sort;
if (sort === undefined || sort === null) {
const next = await pool
.request()
.input('entityId', entityId)
.query(GET_NEXT_SORT);
sort = next.recordset[0].sort;
} else if (!Number.isFinite(sort)) {
throw new Error('Invalid sort');
}
const result = await pool
.request()
.input('entityId', entityId)
@@ -49,6 +61,7 @@ export default function create(pool) {
.input('city', optionalString(input.city))
.input('county', optionalString(input.county))
.input('addressType', addressType)
.input('sort', sort)
.query(CREATE);
return shape(result.recordset[0]);

View File

@@ -14,6 +14,7 @@ function shape(row) {
city: row.city ?? null,
county: row.county ?? null,
addressType: row.addressType,
sort: row.sort,
};
}

View File

@@ -14,6 +14,7 @@ function shape(row) {
city: row.city ?? null,
county: row.county ?? null,
addressType: row.addressType,
sort: row.sort,
};
}
@@ -81,6 +82,14 @@ export default function update(pool) {
}
}
let sort = current.sort;
if (Object.prototype.hasOwnProperty.call(patch, 'sort')) {
if (!Number.isFinite(patch.sort)) {
throw new Error('Invalid sort');
}
sort = patch.sort;
}
const result = await pool
.request()
.input('id', id)
@@ -93,6 +102,7 @@ export default function update(pool) {
.input('city', city)
.input('county', county)
.input('addressType', addressType)
.input('sort', sort)
.query(UPDATE);
const row = result.recordset[0];

View File

@@ -1,4 +1,4 @@
import { CREATE } from '../../sql/entitycontact.js';
import { CREATE, GET_NEXT_SORT } from '../../sql/entitycontact.js';
import resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js';
@@ -8,6 +8,7 @@ function shape(row) {
entityId: row.entityId,
contactType: row.contactType,
contact: row.contact,
sort: row.sort,
};
}
@@ -27,11 +28,23 @@ export default function create(pool) {
await access(googleAuthId, companyId);
await entityAccess(companyId, entityId);
let sort = input.sort;
if (sort === undefined || sort === null) {
const next = await pool
.request()
.input('entityId', entityId)
.query(GET_NEXT_SORT);
sort = next.recordset[0].sort;
} else if (!Number.isFinite(sort)) {
throw new Error('Invalid sort');
}
const result = await pool
.request()
.input('entityId', entityId)
.input('contactType', contactType)
.input('contact', contact)
.input('sort', sort)
.query(CREATE);
return shape(result.recordset[0]);

View File

@@ -8,6 +8,7 @@ function shape(row) {
entityId: row.entityId,
contactType: row.contactType,
contact: row.contact,
sort: row.sort,
};
}

View File

@@ -8,6 +8,7 @@ function shape(row) {
entityId: row.entityId,
contactType: row.contactType,
contact: row.contact,
sort: row.sort,
};
}
@@ -50,12 +51,21 @@ export default function update(pool) {
throw new Error('Contact required');
}
let sort = current.sort;
if (Object.prototype.hasOwnProperty.call(patch, 'sort')) {
if (!Number.isFinite(patch.sort)) {
throw new Error('Invalid sort');
}
sort = patch.sort;
}
const result = await pool
.request()
.input('id', id)
.input('entityId', entityId)
.input('contactType', contactType)
.input('contact', contact)
.input('sort', sort)
.query(UPDATE);
const row = result.recordset[0];