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 | | Method | Signature | Returns |
| --- | --- | --- | | --- | --- | --- |
| `list` | `(googleAuthId, companyId, entityId)` | `EntityAddress[]` | | `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` | | `update` | `(googleAuthId, companyId, entityId, id, patch)` | `EntityAddress` |
| `delete` | `(googleAuthId, companyId, entityId, id)` | `void` | | `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 | | Method | Signature | Returns |
| --- | --- | --- | | --- | --- | --- |
| `list` | `(googleAuthId, companyId, entityId)` | `{ id, entityId, contactType, contact }[]` | | `list` | `(googleAuthId, companyId, entityId)` | `{ id, entityId, contactType, contact, sort }[]` |
| `create` | `(googleAuthId, companyId, entityId, { contactType, contact })` | row | | `create` | `(googleAuthId, companyId, entityId, { contactType, contact, sort? })` | row |
| `update` | `(googleAuthId, companyId, entityId, id, { contactType?, contact? })` | row | | `update` | `(googleAuthId, companyId, entityId, id, { contactType?, contact?, sort? })` | row |
| `delete` | `(googleAuthId, companyId, entityId, id)` | `void` | | `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, sku: string | null,
amount: number, amount: number,
net: number, net: number,
transactedAmount: number,
rebate: number, rebate: number,
tax: number, // tax.rate tax: number, // tax.rate
comment: string | null, comment: string | null,
@@ -310,7 +309,7 @@ type DeliveryLine = {
type Delivery = DeliverySummary & { lines: 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`. `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` ## `posterminal`
Company-scoped POS terminals. Auth via `resolveCompanyAccess`. 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'}`; 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) { function assignEdgeColors(edges) {
const colors = new Map(); const colors = new Map();
let next = 0; let next = 0;
@@ -309,7 +365,7 @@ const CLUSTERS = [
{ {
id: 'commerce', id: 'commerce',
label: 'Pricing & Documents', label: 'Pricing & Documents',
tables: ['tax', 'price', 'manifest', 'manifestarticle'], tables: ['tax', 'price', 'manifest', 'manifestarticle', 'ledger'],
}, },
{ id: 'units', label: 'Units', tables: ['unit'] }, { id: 'units', label: 'Units', tables: ['unit'] },
]; ];
@@ -381,14 +437,142 @@ function parsePlainPositions(plain) {
for (const line of plain.split('\n')) { for (const line of plain.split('\n')) {
if (!line.startsWith('node ')) continue; if (!line.startsWith('node ')) continue;
// node name x y width height ... // 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; 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; 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 * Sort cluster members by their first-pass x so Graphviz's crossing
* minimizer starts from a better initial order within each cluster. * 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. * Head: unique in* ports on the target title bar, ordered by source x.
* Tail: FK column port, always leaving sideways or diagonally * Tail: FK column port, always leaving sideways or diagonally
* (e/w/ne/nw/se/sw) — never straight up/down, which stacks stubs on the * (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) { function assignEdgeAnchors(edges, positions) {
const headPort = new Map(); const headPort = new Map();
@@ -516,10 +701,24 @@ function assignEdgeAnchors(edges, positions) {
return; return;
} }
const fromX = positions?.get(edge.from)?.x ?? 0; const from = positions?.get(edge.from);
const toX = positions?.get(edge.to)?.x ?? fromX; const to = positions?.get(edge.to);
const fromY = positions?.get(edge.from)?.y ?? 0; const fromX = from?.x ?? 0;
const toY = positions?.get(edge.to)?.y ?? fromY; 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 dx = toX - fromX;
const dy = toY - fromY; const dy = toY - fromY;
const absDx = Math.abs(dx); const absDx = Math.abs(dx);
@@ -560,7 +759,7 @@ function assignEdgeAnchors(edges, positions) {
: 'nw'; : '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 edgeColors = assignEdgeColors(edges);
const { headPort, tailCompass, inboundCounts } = assignEdgeAnchors( const rankingEdgeKeys = selectRankingEdgeKeys(edges);
edges, const { headPort, tailCompass, inboundCounts } =
positions anchors ?? assignEdgeAnchors(edges, positions);
);
const lines = [ const lines = [
'digraph schema {', 'digraph schema {',
// TB = landscape for this schema (many tables per rank spread horizontally). // TB = landscape for this schema (many tables per rank spread horizontally).
// mclimit bumps Graphviz's own crossing-minimization iterations. // mclimit bumps Graphviz's own crossing-minimization iterations.
` graph [rankdir=TB, splines=spline, overlap=false, bgcolor="${COLORS.background}",`, ` 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];', ' compound=true, newrank=true, mclimit=4.0];',
' node [shape=plaintext, fontname="Helvetica"];', ' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];', ' 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 // 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. // detached from the actual columns; column→header with :ne still dipped SE.
if (edge.from === edge.to) { if (edge.from === edge.to) {
attrs.push('constraint=false');
lines.push( lines.push(
` ${edge.from}:${sourcePort}:e -> ${edge.to}:id:e [${attrs.join(', ')}];` ` ${edge.from}:${sourcePort}:e -> ${edge.to}:id:e [${attrs.join(', ')}];`
); );
continue; 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( lines.push(
` ${edge.from}:${sourcePort}:${tail} -> ${edge.to}:${targetPort}:n [${attrs.join(', ')}];` ` ${edge.from}:${sourcePort}:${tail} -> ${edge.to}:${targetPort}:n [${attrs.join(', ')}];`
); );
@@ -692,14 +899,16 @@ function countSvgCrossings(svg) {
const paths = [ const paths = [
...svg.matchAll(/<g id="edge\d+"[\s\S]*?<path[^>]*d="([^"]+)"/g), ...svg.matchAll(/<g id="edge\d+"[\s\S]*?<path[^>]*d="([^"]+)"/g),
].map((m) => m[1]); ].map((m) => m[1]);
const segs = [];
const edgeSegs = [];
for (const d of paths) { for (const d of paths) {
const nums = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [ const nums = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [
Number(m[1]), Number(m[1]),
Number(m[2]), Number(m[2]),
]); ]);
const segs = [];
for (let i = 0; i < nums.length - 1; i++) segs.push([nums[i], nums[i + 1]]); for (let i = 0; i < nums.length - 1; i++) segs.push([nums[i], nums[i + 1]]);
edgeSegs.push(segs);
} }
const orient = (a, b, c) => const orient = (a, b, c) =>
@@ -713,19 +922,26 @@ function countSvgCrossings(svg) {
}; };
let n = 0; let n = 0;
for (let i = 0; i < segs.length; i++) { for (let e1 = 0; e1 < edgeSegs.length; e1++) {
for (let j = i + 1; j < segs.length; j++) { for (let e2 = e1 + 1; e2 < edgeSegs.length; e2++) {
const [a, b] = segs[i]; let pairCrosses = false;
const [c, d] = segs[j]; outer: for (const [a, b] of edgeSegs[e1]) {
if ( for (const [c, d] of edgeSegs[e2]) {
(a[0] === c[0] && a[1] === c[1]) || if (
(a[0] === d[0] && a[1] === d[1]) || (a[0] === c[0] && a[1] === c[1]) ||
(b[0] === c[0] && b[1] === c[1]) || (a[0] === d[0] && a[1] === d[1]) ||
(b[0] === d[0] && b[1] === d[1]) (b[0] === c[0] && b[1] === c[1]) ||
) { (b[0] === d[0] && b[1] === d[1])
continue; ) {
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(); const graphviz = await Graphviz.load();
// Two layout passes: learn positions, then reorder columns/cluster members // Layout passes: learn positions, reorder columns/cluster members (barycenter).
// (barycenter) so FK ports and neighbor tables line up and crossings drop.
let positions = null; let positions = null;
for (let pass = 0; pass < 2; pass++) { for (let pass = 0; pass < 3; pass++) {
const probeDot = buildDot(tables, edges, positions); const probeDot = buildDot(tables, edges, positions);
const plain = graphviz.layout(probeDot, 'plain', 'dot'); const plain = graphviz.layout(probeDot, 'plain', 'dot');
positions = parsePlainPositions(plain); positions = parsePlainPositions(plain);
reorderColumnsForCrossingReduction(tables, edges, positions); reorderColumnsForCrossingReduction(tables, edges, positions);
} }
const dot = buildDot(tables, edges, positions); // Hill-climb L/R exits against real spline crossings.
writeFileSync(dotOutputPath, dot, 'utf8'); const baseAnchors = assignEdgeAnchors(edges, positions);
const optimized = optimizeAnchorsWithLayout(
graphviz,
tables,
edges,
positions,
baseAnchors
);
const svg = fixSelfLoopSvg(graphviz.layout(dot, 'svg', 'dot')); writeFileSync(dotOutputPath, optimized.dot, 'utf8');
writeFileSync(outputPath, svg, 'utf8'); writeFileSync(outputPath, optimized.svg, 'utf8');
const dim = svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/); const dim = optimized.svg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
const crossings = countSvgCrossings(svg);
console.log(`Parsed ${tables.size} tables and ${edges.length} foreign keys`); console.log(`Parsed ${tables.size} tables and ${edges.length} foreign keys`);
console.log(`Written ${dotOutputPath}`); console.log(`Written ${dotOutputPath}`);
console.log(`Written ${outputPath}`); console.log(`Written ${outputPath}`);
if (dim) { if (dim) {
console.log( 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, city NVARCHAR(255) NULL,
county NVARCHAR(255) NULL, county NVARCHAR(255) NULL,
addresstype NVARCHAR(50) NOT 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 CONSTRAINT FK_entityaddress_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE
); );
END END
@@ -191,6 +192,7 @@ BEGIN
entity_id INT NOT NULL, entity_id INT NOT NULL,
contacttype NVARCHAR(50) NOT NULL, contacttype NVARCHAR(50) NOT NULL,
contact NVARCHAR(255) 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 CONSTRAINT FK_entitycontact_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE
); );
END END
@@ -520,7 +522,6 @@ BEGIN
article_id INT NULL, article_id INT NULL,
amount FLOAT NOT NULL, amount FLOAT NOT NULL,
net FLOAT NOT NULL, net FLOAT NOT NULL,
transactedamount FLOAT NOT NULL,
comment NVARCHAR(MAX) NULL, comment NVARCHAR(MAX) NULL,
rebate FLOAT NOT NULL CONSTRAINT DF_manifestarticle_rebate DEFAULT 0, rebate FLOAT NOT NULL CONSTRAINT DF_manifestarticle_rebate DEFAULT 0,
tax_id INT NOT NULL, tax_id INT NOT NULL,
@@ -531,6 +532,21 @@ BEGIN
END 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 = ` export const INIT_NUMBERS = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'numbers') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'numbers')
BEGIN BEGIN
@@ -688,6 +704,7 @@ export const SCHEMA_SCRIPTS = [
INIT_PRICE, INIT_PRICE,
INIT_MANIFEST, INIT_MANIFEST,
INIT_MANIFESTARTICLE, INIT_MANIFESTARTICLE,
INIT_LEDGER,
INIT_NUMBERS, INIT_NUMBERS,
INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, INIT_GET_NEXT_NUMBER_WITH_PREFIX_AND_PADDING,
INIT_COMPANYUSER, INIT_COMPANYUSER,

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,6 @@ export function shapeLine(row) {
sku: row.sku ?? null, sku: row.sku ?? null,
amount: row.amount, amount: row.amount,
net: row.net, net: row.net,
transactedAmount: row.transactedAmount,
rebate: row.rebate, rebate: row.rebate,
tax: row.tax, tax: row.tax,
comment: row.comment ?? null, comment: row.comment ?? null,
@@ -80,10 +79,6 @@ export function normalizeLines(lines) {
const rebate = const rebate =
entry?.rebate === undefined || entry?.rebate === null ? 0 : entry.rebate; entry?.rebate === undefined || entry?.rebate === null ? 0 : entry.rebate;
const tax = entry?.tax; const tax = entry?.tax;
const transactedAmount =
entry?.transactedAmount === undefined || entry?.transactedAmount === null
? amount
: entry.transactedAmount;
const comment = const comment =
entry?.comment === undefined || entry?.comment === null entry?.comment === undefined || entry?.comment === null
? null ? null
@@ -93,12 +88,11 @@ export function normalizeLines(lines) {
!Number.isFinite(amount) || !Number.isFinite(amount) ||
!Number.isFinite(net) || !Number.isFinite(net) ||
!Number.isFinite(rebate) || !Number.isFinite(rebate) ||
!Number.isFinite(tax) || !Number.isFinite(tax)
!Number.isFinite(transactedAmount)
) { ) {
throw new Error('Invalid lines'); 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('articleId', articleId)
.input('amount', line.amount) .input('amount', line.amount)
.input('net', line.net) .input('net', line.net)
.input('transactedAmount', line.transactedAmount)
.input('comment', line.comment) .input('comment', line.comment)
.input('rebate', line.rebate) .input('rebate', line.rebate)
.input('taxId', taxId) .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 resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
@@ -14,6 +14,7 @@ function shape(row) {
city: row.city ?? null, city: row.city ?? null,
county: row.county ?? null, county: row.county ?? null,
addressType: row.addressType, addressType: row.addressType,
sort: row.sort,
}; };
} }
@@ -38,6 +39,17 @@ export default function create(pool) {
await access(googleAuthId, companyId); await access(googleAuthId, companyId);
await entityAccess(companyId, entityId); 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 const result = await pool
.request() .request()
.input('entityId', entityId) .input('entityId', entityId)
@@ -49,6 +61,7 @@ export default function create(pool) {
.input('city', optionalString(input.city)) .input('city', optionalString(input.city))
.input('county', optionalString(input.county)) .input('county', optionalString(input.county))
.input('addressType', addressType) .input('addressType', addressType)
.input('sort', sort)
.query(CREATE); .query(CREATE);
return shape(result.recordset[0]); return shape(result.recordset[0]);

View File

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

View File

@@ -14,6 +14,7 @@ function shape(row) {
city: row.city ?? null, city: row.city ?? null,
county: row.county ?? null, county: row.county ?? null,
addressType: row.addressType, 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 const result = await pool
.request() .request()
.input('id', id) .input('id', id)
@@ -93,6 +102,7 @@ export default function update(pool) {
.input('city', city) .input('city', city)
.input('county', county) .input('county', county)
.input('addressType', addressType) .input('addressType', addressType)
.input('sort', sort)
.query(UPDATE); .query(UPDATE);
const row = result.recordset[0]; 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 resolveCompanyAccess from '../companyuser/resolveCompanyAccess.js';
import resolveEntityAccess from '../entity/resolveEntityAccess.js'; import resolveEntityAccess from '../entity/resolveEntityAccess.js';
@@ -8,6 +8,7 @@ function shape(row) {
entityId: row.entityId, entityId: row.entityId,
contactType: row.contactType, contactType: row.contactType,
contact: row.contact, contact: row.contact,
sort: row.sort,
}; };
} }
@@ -27,11 +28,23 @@ export default function create(pool) {
await access(googleAuthId, companyId); await access(googleAuthId, companyId);
await entityAccess(companyId, entityId); 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 const result = await pool
.request() .request()
.input('entityId', entityId) .input('entityId', entityId)
.input('contactType', contactType) .input('contactType', contactType)
.input('contact', contact) .input('contact', contact)
.input('sort', sort)
.query(CREATE); .query(CREATE);
return shape(result.recordset[0]); return shape(result.recordset[0]);

View File

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

View File

@@ -8,6 +8,7 @@ function shape(row) {
entityId: row.entityId, entityId: row.entityId,
contactType: row.contactType, contactType: row.contactType,
contact: row.contact, contact: row.contact,
sort: row.sort,
}; };
} }
@@ -50,12 +51,21 @@ export default function update(pool) {
throw new Error('Contact required'); 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 const result = await pool
.request() .request()
.input('id', id) .input('id', id)
.input('entityId', entityId) .input('entityId', entityId)
.input('contactType', contactType) .input('contactType', contactType)
.input('contact', contact) .input('contact', contact)
.input('sort', sort)
.query(UPDATE); .query(UPDATE);
const row = result.recordset[0]; const row = result.recordset[0];