mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
255 lines
7.5 KiB
TypeScript
255 lines
7.5 KiB
TypeScript
import type {
|
|
ContractDocumentArticle,
|
|
ContractDocumentSnapshot,
|
|
} from './entities/contract.entity';
|
|
|
|
/**
|
|
* One recorded change between two document snapshots. A body edit carries the
|
|
* text on both sides so the audit trail shows WHAT was rewritten, not merely
|
|
* that something was — the UI diffs the two strings for display.
|
|
*/
|
|
export type ContractDocumentChange =
|
|
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
|
| { kind: 'ARTICLE_REMOVED'; articleId: string; title: string }
|
|
| {
|
|
kind: 'ARTICLE_RENAMED';
|
|
articleId: string;
|
|
title: string;
|
|
fromTitle: string;
|
|
}
|
|
| {
|
|
kind: 'ARTICLE_BODY_CHANGED';
|
|
articleId: string;
|
|
title: string;
|
|
/** Body before / after the edit. Absent on revisions recorded earlier. */
|
|
fromBody?: string;
|
|
toBody?: string;
|
|
}
|
|
| {
|
|
kind: 'ARTICLE_REORDERED';
|
|
articleId: string;
|
|
title: string;
|
|
fromOrder: number;
|
|
toOrder: number;
|
|
}
|
|
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
|
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number }
|
|
/**
|
|
* A contract field (not a document article) changed — the customer editing a
|
|
* DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type.
|
|
*/
|
|
| {
|
|
kind: 'FIELD_CHANGED';
|
|
field: string;
|
|
label: string;
|
|
from: string | null;
|
|
to: string | null;
|
|
};
|
|
|
|
type SnapshotLike = Pick<
|
|
ContractDocumentSnapshot,
|
|
'documentTitle' | 'whereasClauses' | 'articles'
|
|
> | null;
|
|
|
|
/** Match on id when present, else on normalized title (editors may omit ids). */
|
|
function articleKey(article: ContractDocumentArticle): string {
|
|
return article.id || `title:${article.title.trim().toLowerCase()}`;
|
|
}
|
|
|
|
function indexArticles(
|
|
articles: ContractDocumentArticle[] | undefined,
|
|
): Map<string, ContractDocumentArticle> {
|
|
const map = new Map<string, ContractDocumentArticle>();
|
|
for (const article of articles ?? []) {
|
|
map.set(articleKey(article), article);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Compare two document snapshots and describe what changed, article by article.
|
|
* Returns an empty array when the snapshots are equivalent, so callers can skip
|
|
* recording a no-op revision.
|
|
*/
|
|
export function diffSnapshots(
|
|
before: SnapshotLike,
|
|
after: SnapshotLike,
|
|
): ContractDocumentChange[] {
|
|
const changes: ContractDocumentChange[] = [];
|
|
|
|
const beforeTitle = before?.documentTitle ?? null;
|
|
const afterTitle = after?.documentTitle ?? null;
|
|
if (beforeTitle !== afterTitle && afterTitle !== null) {
|
|
changes.push({
|
|
kind: 'DOCUMENT_TITLE_CHANGED',
|
|
title: afterTitle,
|
|
fromTitle: beforeTitle,
|
|
});
|
|
}
|
|
|
|
const beforeWhereas = before?.whereasClauses ?? [];
|
|
const afterWhereas = after?.whereasClauses ?? [];
|
|
const beforeWhereasSet = new Set(beforeWhereas);
|
|
const afterWhereasSet = new Set(afterWhereas);
|
|
const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length;
|
|
const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length;
|
|
if (whereasAdded > 0 || whereasRemoved > 0) {
|
|
changes.push({
|
|
kind: 'WHEREAS_CHANGED',
|
|
added: whereasAdded,
|
|
removed: whereasRemoved,
|
|
});
|
|
}
|
|
|
|
const beforeArticles = indexArticles(before?.articles);
|
|
const afterArticles = indexArticles(after?.articles);
|
|
|
|
for (const [key, article] of afterArticles) {
|
|
const previous = beforeArticles.get(key);
|
|
if (!previous) {
|
|
changes.push({
|
|
kind: 'ARTICLE_ADDED',
|
|
articleId: article.id,
|
|
title: article.title,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (previous.title !== article.title) {
|
|
changes.push({
|
|
kind: 'ARTICLE_RENAMED',
|
|
articleId: article.id,
|
|
title: article.title,
|
|
fromTitle: previous.title,
|
|
});
|
|
}
|
|
if (previous.body !== article.body) {
|
|
changes.push({
|
|
kind: 'ARTICLE_BODY_CHANGED',
|
|
articleId: article.id,
|
|
title: article.title,
|
|
fromBody: previous.body,
|
|
toBody: article.body,
|
|
});
|
|
}
|
|
if (previous.order !== article.order) {
|
|
changes.push({
|
|
kind: 'ARTICLE_REORDERED',
|
|
articleId: article.id,
|
|
title: article.title,
|
|
fromOrder: previous.order,
|
|
toOrder: article.order,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const [key, article] of beforeArticles) {
|
|
if (afterArticles.has(key)) continue;
|
|
changes.push({
|
|
kind: 'ARTICLE_REMOVED',
|
|
articleId: article.id,
|
|
title: article.title,
|
|
});
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
/** Human label per audited contract field, in the order they read on the form. */
|
|
export const CONTRACT_FIELD_LABELS: Record<string, string> = {
|
|
contractKind: 'Contract kind',
|
|
tradeDirection: 'Trade direction',
|
|
freightType: 'Freight type',
|
|
serviceType: 'Service type',
|
|
paymentCurrency: 'Payment currency',
|
|
contractType: 'Contract type',
|
|
isHazardous: 'Hazardous',
|
|
hazardClass: 'Hazard class',
|
|
unNumber: 'UN number',
|
|
isReefer: 'Reefer',
|
|
equipmentReturn: 'Equipment return',
|
|
customsClearingAgent: 'Customs clearing agent',
|
|
firstMilePickupAddress: 'First-mile pickup address',
|
|
lastMileDeliveryAddress: 'Last-mile delivery address',
|
|
routes: 'Routes',
|
|
cargoScope: 'Cargo scope',
|
|
};
|
|
|
|
/** Render a field value for the audit trail — never "[object Object]". */
|
|
function displayValue(value: unknown): string | null {
|
|
if (value === null || value === undefined || value === '') return null;
|
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
return String(value);
|
|
}
|
|
|
|
/**
|
|
* Compare two flat maps of contract fields and report what changed. Only keys
|
|
* present in `after` are considered, so a partial update never reports the
|
|
* fields it did not touch.
|
|
*/
|
|
export function diffContractFields(
|
|
before: Record<string, unknown>,
|
|
after: Record<string, unknown>,
|
|
): ContractDocumentChange[] {
|
|
const changes: ContractDocumentChange[] = [];
|
|
|
|
for (const [field, nextRaw] of Object.entries(after)) {
|
|
const next = displayValue(nextRaw);
|
|
const previous = displayValue(before[field]);
|
|
if (next === previous) continue;
|
|
changes.push({
|
|
kind: 'FIELD_CHANGED',
|
|
field,
|
|
label: CONTRACT_FIELD_LABELS[field] ?? field,
|
|
from: previous,
|
|
to: next,
|
|
});
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
|
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
|
if (changes.length === 0) return 'No changes';
|
|
|
|
const articleVerbs: Record<string, string> = {
|
|
ARTICLE_ADDED: 'added',
|
|
ARTICLE_REMOVED: 'removed',
|
|
ARTICLE_RENAMED: 'renamed',
|
|
ARTICLE_BODY_CHANGED: 'edited',
|
|
ARTICLE_REORDERED: 'reordered',
|
|
};
|
|
|
|
const counts = new Map<string, number>();
|
|
const parts: string[] = [];
|
|
const fields: string[] = [];
|
|
|
|
for (const change of changes) {
|
|
const verb = articleVerbs[change.kind];
|
|
if (verb) {
|
|
counts.set(verb, (counts.get(verb) ?? 0) + 1);
|
|
} else if (change.kind === 'DOCUMENT_TITLE_CHANGED') {
|
|
parts.push('document title changed');
|
|
} else if (change.kind === 'WHEREAS_CHANGED') {
|
|
parts.push('recitals changed');
|
|
} else if (change.kind === 'FIELD_CHANGED') {
|
|
fields.push(change.label.toLowerCase());
|
|
}
|
|
}
|
|
|
|
if (fields.length > 0) {
|
|
parts.push(
|
|
fields.length <= 3
|
|
? `${fields.join(', ')} changed`
|
|
: `${fields.length} contract fields changed`,
|
|
);
|
|
}
|
|
|
|
const articleParts = [...counts.entries()].map(
|
|
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
|
);
|
|
|
|
return [...articleParts, ...parts].join(', ');
|
|
}
|