Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts

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(', ');
}