2026-10-16 10:24:39 -05:00
|
|
|
import { escapeODataString } from '../../core/sl/odata';
|
|
|
|
|
import { type HeaderField, keysOfFields, requiredOfFields } from '../documents/document-layouts';
|
|
|
|
|
import { type DocRecord, compact } from '../documents/document-model';
|
2026-10-16 12:31:27 -05:00
|
|
|
import type { MasterCollection, MasterLayout } from './master-types';
|
2026-10-16 10:24:39 -05:00
|
|
|
|
|
|
|
|
const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
|
|
|
|
|
|
|
|
|
/** Todos los campos del formulario: los de arriba y los de cada pestaña. */
|
|
|
|
|
export const masterFieldsOf = (layout: MasterLayout): HeaderField[] => [
|
|
|
|
|
...layout.header.left,
|
|
|
|
|
...layout.header.right,
|
|
|
|
|
...layout.tabs.flatMap((t) => [...t.left, ...t.right]),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/** Campos de SAP que se leen, se editan y se envían. */
|
|
|
|
|
export const masterKeysOf = (layout: MasterLayout): readonly string[] => keysOfFields(masterFieldsOf(layout));
|
|
|
|
|
|
|
|
|
|
/** Campos obligatorios con el texto del aviso ("Indique el código."). */
|
|
|
|
|
export const masterRequiredOf = (layout: MasterLayout): { key: string; label: string }[] => requiredOfFields(masterFieldsOf(layout));
|
|
|
|
|
|
|
|
|
|
/** Campos que no se pueden cambiar una vez creado el registro (el código). */
|
|
|
|
|
export const masterImmutableOf = (layout: MasterLayout): ReadonlySet<string> =>
|
|
|
|
|
new Set(masterFieldsOf(layout).flatMap((f) => (f.type === 'text' && f.immutable ? [f.key] : [])));
|
|
|
|
|
|
|
|
|
|
/** Valores editables de un registro de SAP (los vacíos quedan como texto vacío). */
|
|
|
|
|
export function pickMaster(doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord {
|
|
|
|
|
if (!doc) return blank;
|
|
|
|
|
return Object.fromEntries(keys.map((k) => [k, doc[k] ?? '']));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Errores que se pueden detectar sin preguntarle a SAP. */
|
|
|
|
|
export function validateMaster(header: DocRecord, required: readonly { key: string; label: string }[]): string[] {
|
|
|
|
|
return required.filter((f) => isEmpty(header[f.key])).map((f) => `Indique ${f.label.toLowerCase()}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Cuerpo para crear: cabecera sin campos vacíos más los valores fijos de la pantalla. */
|
|
|
|
|
export const createMasterPayload = (header: DocRecord, fixed?: DocRecord): DocRecord => ({ ...compact(header), ...fixed });
|
|
|
|
|
|
|
|
|
|
export type MasterNavTarget = 'first' | 'prev' | 'next' | 'last';
|
|
|
|
|
|
|
|
|
|
/** Consulta de un solo registro para ir al primero / anterior / siguiente / último por su clave de texto. */
|
|
|
|
|
export function masterNavigationQuery(
|
|
|
|
|
target: MasterNavTarget,
|
|
|
|
|
keyField: string,
|
|
|
|
|
current: string | null,
|
|
|
|
|
): { $filter?: string; $orderby: string } {
|
|
|
|
|
const asc = `${keyField} asc`;
|
|
|
|
|
const desc = `${keyField} desc`;
|
|
|
|
|
const quoted = current === null ? '' : `'${escapeODataString(current)}'`;
|
|
|
|
|
switch (target) {
|
|
|
|
|
case 'first':
|
|
|
|
|
return { $orderby: asc };
|
|
|
|
|
case 'last':
|
|
|
|
|
return { $orderby: desc };
|
|
|
|
|
case 'prev':
|
|
|
|
|
return current === null ? { $orderby: desc } : { $filter: `${keyField} lt ${quoted}`, $orderby: desc };
|
|
|
|
|
case 'next':
|
|
|
|
|
return current === null ? { $orderby: asc } : { $filter: `${keyField} gt ${quoted}`, $orderby: asc };
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-16 12:31:27 -05:00
|
|
|
|
|
|
|
|
// --- tablas (colecciones) ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export type CollectionRow = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
/** Las tablas que declara el formulario, de todas sus pestañas. */
|
|
|
|
|
export const masterCollectionsOf = (layout: MasterLayout): MasterCollection[] =>
|
|
|
|
|
layout.tabs.flatMap((t) => (t.collection ? [t.collection] : []));
|
|
|
|
|
|
|
|
|
|
/** Copia de las filas que trae SAP de una colección (sobre ellas se aplican los cambios). */
|
|
|
|
|
export const collectionRowsOf = (doc: DocRecord | undefined, c: MasterCollection): CollectionRow[] =>
|
|
|
|
|
((doc?.[c.key] as CollectionRow[] | undefined) ?? []).map((r) => ({ ...r }));
|
|
|
|
|
|
|
|
|
|
const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === ''));
|
|
|
|
|
|
|
|
|
|
/** Una fila vacía: ninguno de los campos que la identifican tiene valor. */
|
|
|
|
|
export const isBlankRow = (c: MasterCollection, row: CollectionRow): boolean => !c.identity.some((k) => has(row[k]));
|
|
|
|
|
|
|
|
|
|
const editableKeys = (c: MasterCollection): string[] => c.columns.filter((col) => !col.readonly).map((col) => col.key);
|
|
|
|
|
/** Dos valores de una celda son iguales (vacío = vacío; un número escrito como texto = el número). */
|
|
|
|
|
function same(a: unknown, b: unknown): boolean {
|
|
|
|
|
if (!has(a) && !has(b)) return true;
|
|
|
|
|
if (!has(a) || !has(b)) return false;
|
|
|
|
|
return a === b || (Number.isFinite(Number(a)) && Number(a) === Number(b));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Lo que se envía a SAP de una tabla tras editarla, o `null` si no cambió. En `replace` la colección completa
|
|
|
|
|
* (las filas leídas con los cambios y las nuevas, sin las vacías); en `merge` solo las filas cambiadas con su
|
|
|
|
|
* `rowKey` y los campos que cambiaron.
|
|
|
|
|
*/
|
|
|
|
|
export function collectionPayload(c: MasterCollection, original: readonly CollectionRow[], current: readonly CollectionRow[]): CollectionRow[] | null {
|
|
|
|
|
if (c.readonly) return null;
|
|
|
|
|
const keys = editableKeys(c);
|
|
|
|
|
const rows = current.filter((r) => !isBlankRow(c, r));
|
|
|
|
|
if (c.mode === 'merge') {
|
|
|
|
|
const rk = c.rowKey ?? '';
|
|
|
|
|
const changed = rows.flatMap((r) => {
|
|
|
|
|
const before = original.find((o) => o[rk] === r[rk]);
|
|
|
|
|
const diff = keys.filter((k) => !same(before?.[k], r[k]));
|
|
|
|
|
return before && diff.length ? [{ [rk]: r[rk], ...Object.fromEntries(diff.map((k) => [k, has(r[k]) ? r[k] : null])) }] : [];
|
|
|
|
|
});
|
|
|
|
|
return changed.length ? changed : null;
|
|
|
|
|
}
|
|
|
|
|
const unchanged = rows.length === original.length && rows.every((r, i) => keys.every((k) => same(original[i]?.[k], r[k])));
|
|
|
|
|
return unchanged ? null : rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Tablas con filas para enviar al crear el registro (las vacías no). */
|
|
|
|
|
export function collectionsCreatePayload(layout: MasterLayout, current: Readonly<Record<string, readonly CollectionRow[]>>): DocRecord {
|
|
|
|
|
const body: DocRecord = {};
|
|
|
|
|
for (const c of masterCollectionsOf(layout)) {
|
|
|
|
|
if (c.readonly) continue;
|
|
|
|
|
const rows = (current[c.key] ?? []).filter((r) => !isBlankRow(c, r)).map((r) => compact(r));
|
|
|
|
|
if (rows.length) body[c.key] = rows;
|
|
|
|
|
}
|
|
|
|
|
return body;
|
|
|
|
|
}
|