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:
32
README.md
32
README.md
@@ -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`.
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
170
schema-graph.dot
170
schema-graph.dot
File diff suppressed because one or more lines are too long
3041
schema-graph.svg
3041
schema-graph.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 186 KiB |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TABLES = [
|
||||
'posterminalcategory',
|
||||
'posterminal',
|
||||
'numbers',
|
||||
'ledger',
|
||||
'manifestarticle',
|
||||
'manifest',
|
||||
'price',
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -14,6 +14,7 @@ function shape(row) {
|
||||
city: row.city ?? null,
|
||||
county: row.county ?? null,
|
||||
addressType: row.addressType,
|
||||
sort: row.sort,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -8,6 +8,7 @@ function shape(row) {
|
||||
entityId: row.entityId,
|
||||
contactType: row.contactType,
|
||||
contact: row.contact,
|
||||
sort: row.sort,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user