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 |
|
| 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`.
|
||||||
|
|||||||
@@ -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`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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,
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const TABLES = [
|
|||||||
'posterminalcategory',
|
'posterminalcategory',
|
||||||
'posterminal',
|
'posterminal',
|
||||||
'numbers',
|
'numbers',
|
||||||
|
'ledger',
|
||||||
'manifestarticle',
|
'manifestarticle',
|
||||||
'manifest',
|
'manifest',
|
||||||
'price',
|
'price',
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user