fix contrat,wagon allocation wehn a booking is split

This commit is contained in:
Marshal
2026-07-10 22:27:58 +00:00
parent 1121e9ce82
commit 5d8658b5d6
12 changed files with 475 additions and 79 deletions

View File

@@ -3,6 +3,10 @@ import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
@@ -16,10 +20,26 @@ export interface RenderedArticle {
}
/**
* Parse a template article body into clauses. Format: one clause per line;
* lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single clause without bullets renders as a plain
* paragraph rather than a numbered list of one.
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
* The token's segment count sets the clause depth; its digits are ignored —
* numbering is recomputed sequentially so stale numbers self-heal.
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
* end the line — that is an empty clause still being typed in the editor.
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
const MAX_CLAUSE_DEPTH = 6;
/**
* Parse a template article body into clauses. Format: one clause per line.
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
* sub-clause at that depth — the typed digits are stripped and renumbered
* sequentially, so editing order never leaves stale numbers in the document.
* Lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single un-numbered clause without bullets renders
* as a plain paragraph rather than a numbered list of one.
*/
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
const lines = (body ?? '')
@@ -28,20 +48,44 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
.filter((line) => line.length > 0);
const clauses: RenderedClause[] = [];
// counters[i] = current number at depth i+1; truncated when a shallower
// clause arrives so deeper numbering restarts at 1.
const counters: number[] = [];
let sawNumberToken = false;
for (const line of lines) {
if (line.startsWith('- ')) {
const bullet = line.slice(2).trim();
if (clauses.length === 0) {
clauses.push({ text: bullet, bullets: [] });
counters.splice(0, counters.length, 1);
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
} else {
clauses[clauses.length - 1].bullets.push(bullet);
}
} else {
clauses.push({ text: line, bullets: [] });
continue;
}
const match = CLAUSE_NUMBER_RE.exec(line);
const token = match ? (match[1] ?? match[2]) : null;
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
// A sub-clause can only sit directly under an existing parent — "1.1.1"
// typed as the first line clamps to whatever level is actually open.
depth = Math.min(depth, counters.length + 1);
if (match) sawNumberToken = true;
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
clauses.push({
text: match ? line.slice(match[0].length).trim() : line,
number: counters.slice(0, depth).join('.'),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };

View File

@@ -26,6 +26,40 @@ describe('parseArticleBody', () => {
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
expect(parsed.clauses).toEqual([]);
});
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
const parsed = parseArticleBody(
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
);
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
['1', 1, 'Scope'],
['1.1', 2, 'Rail transport'],
['1.1.1', 3, 'Wagon supply'],
['2', 1, 'Payment'],
]);
});
it('clamps a sub-clause with no open parent to the next available level', () => {
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
['1', 1],
['2', 1],
]);
});
it('leaves prose that merely starts with a number un-tokenized', () => {
const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.');
expect(parsed.clauses.map((c) => c.text)).toEqual([
'10 tons is the minimum load.',
'Payment in advance.',
]);
});
it('keeps a single explicitly numbered line as a clause, not a paragraph', () => {
const parsed = parseArticleBody('1. Only clause.');
expect(parsed.paragraph).toBeUndefined();
expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]);
});
});
describe('interpolateTemplateText', () => {

View File

@@ -6,7 +6,8 @@
{{else}}
<ol class="clauses">
{{#each clauses}}
<li>
<li class="clause depth-{{depth}}">
<span class="clause-no">{{number}}.</span>
{{text}}
{{#if bullets.length}}
<ul class="clause-bullets">

View File

@@ -282,28 +282,27 @@
.article-name { color: #0e5b45; }
.article-paragraph { margin: 4px 0 0; }
ol.clauses {
counter-reset: clause;
list-style: none;
margin: 6px 0 0;
padding-left: 0;
}
ol.clauses > li {
counter-increment: clause;
ol.clauses > li.clause {
margin-bottom: 6px;
padding-left: 24px;
position: relative;
text-align: justify;
}
ol.clauses > li::before {
ol.clauses .clause-no {
color: #0e5b45;
content: counter(clause) ".";
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-weight: 700;
left: 0;
position: absolute;
top: 1px;
margin-right: 6px;
}
/* Sub-clause indentation: each outline level steps in. */
ol.clauses > li.depth-2 { padding-left: 20px; }
ol.clauses > li.depth-3 { padding-left: 40px; }
ol.clauses > li.depth-4 { padding-left: 60px; }
ol.clauses > li.depth-5 { padding-left: 80px; }
ol.clauses > li.depth-6 { padding-left: 100px; }
ul.clause-bullets {
margin: 5px 0 2px;
padding-left: 16px;