d4b8c4e7e4
null там законный (очистка колонки SharePoint, ответ localStorage), строка javascript: в тесте нужна намеренно — пометил с объяснением. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
6.1 KiB
TypeScript
145 lines
6.1 KiB
TypeScript
/**
|
|
* Журнал правок согласующего: какие поля заявки он поменял на своём шаге и
|
|
* какими они были до этого. Хранится JSON-массивом в Note-колонке заявки
|
|
* (у каждой формы своя — см. provisioning.json), показывается подсветкой поля
|
|
* и строкой «Было: …» (см. shared/ui/FormField).
|
|
*
|
|
* Сейчас пишется только при полном праве правки (shared/approval/fullEditRights.ts).
|
|
*/
|
|
|
|
export interface IFieldChange {
|
|
/** Ключ поля = internalName колонки = htmlFor (или fieldKey) поля формы. */
|
|
field: string;
|
|
/** Значение ДО первой правки согласующего — в читаемом виде. */
|
|
before: string;
|
|
/** Значение после последней правки — в читаемом виде. */
|
|
after: string;
|
|
byName: string;
|
|
byEmail: string;
|
|
/** ISO-время последней правки этого поля. */
|
|
at: string;
|
|
}
|
|
|
|
export type FieldChangeMap = Record<string, IFieldChange>;
|
|
|
|
/** Кто правит — для записи в журнал. */
|
|
export interface IFieldEditor {
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
/** Своё отображение значения поля: получает значение и весь снимок формы. */
|
|
export type FieldFormatter<T> = (value: unknown, data: T) => string;
|
|
|
|
export interface IChangeTrackingConfig<T> {
|
|
/** Технические ключи без своего поля на форме (Id из справочников и т.п.) — их правки не пишем. */
|
|
ignore: ReadonlyArray<keyof T>;
|
|
/** Поля, у которых значение само по себе нечитаемо (Id компании → её название). */
|
|
formatters?: Partial<Record<keyof T, FieldFormatter<T>>>;
|
|
}
|
|
|
|
/** Пустое значение в журнале и в строке «Было: …». */
|
|
export const EMPTY_VALUE = '—';
|
|
|
|
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
|
|
function formatItem(item: unknown): string {
|
|
if (typeof item === 'string') return item;
|
|
if (item && typeof item === 'object') {
|
|
const record = item as Record<string, unknown>;
|
|
// Компании справочника — { Id, Name }.
|
|
if (typeof record.Name === 'string') return record.Name;
|
|
// Языки — { language, level }.
|
|
if (typeof record.language === 'string') {
|
|
return record.level ? `${record.language} (${String(record.level)})` : record.language;
|
|
}
|
|
}
|
|
return JSON.stringify(item);
|
|
}
|
|
|
|
/** Значение поля формы → читаемая строка (дата ДД.ММ.ГГГГ, «Да/Нет», списки через запятую). */
|
|
export function formatFieldValue(value: unknown): string {
|
|
if (value === null || value === undefined) return EMPTY_VALUE;
|
|
if (typeof value === 'boolean') return value ? 'Да' : 'Нет';
|
|
if (typeof value === 'number') return String(value);
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return EMPTY_VALUE;
|
|
const date = trimmed.match(ISO_DATE);
|
|
return date ? `${date[3]}.${date[2]}.${date[1]}` : trimmed;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.length > 0 ? value.map(formatItem).join(', ') : EMPTY_VALUE;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function isFieldChange(item: unknown): item is IFieldChange {
|
|
const record = item as Record<string, unknown> | null;
|
|
return !!record && typeof record.field === 'string' && typeof record.before === 'string';
|
|
}
|
|
|
|
/** Разбор JSON журнала из колонки; битое/пустое значение — пустой журнал. */
|
|
export function parseFieldChanges(raw: unknown): IFieldChange[] {
|
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed.filter(isFieldChange) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Журнал → JSON для колонки (null — журнал пуст, колонку очищаем). */
|
|
// eslint-disable-next-line @rushstack/no-new-null -- null очищает колонку SharePoint, undefined поле бы не тронул
|
|
export function serializeFieldChanges(changes: IFieldChange[]): string | null {
|
|
return changes.length > 0 ? JSON.stringify(changes) : null;
|
|
}
|
|
|
|
export function toChangeMap(changes: IFieldChange[]): FieldChangeMap {
|
|
const map: FieldChangeMap = {};
|
|
changes.forEach((c) => {
|
|
map[c.field] = c;
|
|
});
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Дописать в журнал правки одного сохранения.
|
|
*
|
|
* saved — то, что сейчас лежит в заявке (до этого сохранения), edited — то,
|
|
* что сохраняем. В «было» остаётся значение ДО ПЕРВОЙ правки поля — при
|
|
* повторных сохранениях обновляется только «стало». Если поле вернули к
|
|
* исходному значению, запись о нём убирается.
|
|
*/
|
|
export function mergeFieldChanges<T extends object>(
|
|
previous: IFieldChange[],
|
|
saved: T,
|
|
edited: T,
|
|
config: IChangeTrackingConfig<T>,
|
|
editor: IFieldEditor,
|
|
now: Date
|
|
): IFieldChange[] {
|
|
const display = (key: keyof T, data: T): string => {
|
|
const formatter = config.formatters?.[key];
|
|
return formatter ? formatter(data[key], data) : formatFieldValue(data[key]);
|
|
};
|
|
|
|
const byField = toChangeMap(previous);
|
|
(Object.keys(edited) as (keyof T)[]).forEach((key) => {
|
|
if (config.ignore.indexOf(key) !== -1) return;
|
|
if (JSON.stringify(saved[key]) === JSON.stringify(edited[key])) return;
|
|
|
|
const field = String(key);
|
|
const after = display(key, edited);
|
|
const before = byField[field] ? byField[field].before : display(key, saved);
|
|
if (before === after) {
|
|
delete byField[field];
|
|
return;
|
|
}
|
|
byField[field] = { field, before, after, byName: editor.name, byEmail: editor.email, at: now.toISOString() };
|
|
});
|
|
|
|
return Object.keys(byField).map((field) => byField[field]);
|
|
}
|