Files
basic-erp/apps/web/src/app/features/documents/document-model.ts
T

251 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { GridLine } from '../../shared/grid/grid.types';
import { hasBaseRef } from './document-objects';
import { DETAIL_KEYS, detailRowsPayload } from './line-details';
/** Documento tal como lo devuelve / recibe el Service Layer. */
export type DocRecord = Record<string, unknown>;
const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100;
const num = (v: unknown): number => {
const n = Number(v ?? 0);
return Number.isFinite(n) ? n : 0;
};
const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
// --- estado -------------------------------------------------------------------------------
/** Cerrado o cancelado: el documento solo se puede consultar. */
export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean {
if (!doc) return false;
return doc['Cancelled'] === 'tYES' || doc['DocumentStatus'] === 'bost_Close';
}
// --- totales ------------------------------------------------------------------------------
/** Gasto adicional del documento (el flete): colección `DocumentAdditionalExpenses` de SAP. */
export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses';
export interface DocTotals {
/** Flete (gastos adicionales) antes de impuestos. */
freight: number;
/** Suma de los totales de línea (ya con el descuento de cada línea). */
subtotal: number;
/** Descuento del documento (% de cabecera aplicado al subtotal). */
discount: number;
/** Total antes de impuestos. */
net: number;
tax: number;
total: number;
}
/** `Code → tasa %` a partir del maestro `SalesTaxCodes`. */
export function taxRateMap(rows: readonly DocRecord[]): ReadonlyMap<string, number> {
return new Map(rows.map((r) => [String(r['Code']), num(r['Rate'])]));
}
/**
* Totales informativos del documento. SAP los recalcula al guardar (y es quien manda), pero el
* usuario los ve mientras edita. Impuesto = total de línea con el descuento de cabecera × tasa.
*/
export function computeTotals(
lines: readonly GridLine[],
rates: ReadonlyMap<string, number>,
discountPercent: number,
freight = 0,
): DocTotals {
const factor = 1 - discountPercent / 100;
let subtotal = 0;
let tax = 0;
for (const line of lines) {
const lineTotal = num(line['LineTotal']);
subtotal += lineTotal;
tax += lineTotal * factor * (rates.get(String(line['TaxCode'] ?? '')) ?? 0) / 100;
}
const net = subtotal * factor;
return {
subtotal: round2(subtotal),
discount: round2(subtotal - net),
net: round2(net),
tax: round2(tax),
freight: round2(freight),
total: round2(net + tax + freight),
};
}
// --- flete ---------------------------------------------------------------------------------
/** Flete del documento tal como se edita: el código del gasto y el importe (`FreightExpense`, `FreightAmount`). */
export function freightOf(doc: DocRecord | undefined): { expense: string; amount: number; editable: boolean } {
const rows = (doc?.[FREIGHT_COLLECTION] as DocRecord[] | undefined) ?? [];
return {
expense: rows[0]?.['ExpenseCode'] === undefined || rows[0]?.['ExpenseCode'] === null ? '' : String(rows[0]['ExpenseCode']),
amount: round2(rows.reduce((sum, r) => sum + num(r['LineTotal']), 0)),
// con varios gastos adicionales (cargados en SAP) el flete solo se muestra: no se puede reducir a una fila
editable: rows.length <= 1,
};
}
const FREIGHT_KEYS = ['FreightExpense', 'FreightAmount'] as const;
const withoutFreight = (r: DocRecord): DocRecord => Object.fromEntries(Object.entries(r).filter(([k]) => !(FREIGHT_KEYS as readonly string[]).includes(k)));
/**
* Convierte los campos del flete del formulario (`FreightExpense`, `FreightAmount`) en la colección
* `DocumentAdditionalExpenses` que espera SAP. `expense` es el código elegido o, si no eligió, el
* de flete por defecto. Al crear, sin importe no se envía nada; al modificar solo si cambió (con
* `replace`, porque SAP reemplaza la colección entera) y un importe 0 quita el flete.
*/
export function freightPayload(
body: DocRecord,
header: DocRecord,
expense: string,
mode: 'create' | 'update',
hadFreight = false,
): { body: DocRecord; replace: boolean } {
const touched = FREIGHT_KEYS.some((k) => k in body);
if (mode === 'update' && !touched) return { body, replace: false };
const amount = round2(num(header['FreightAmount']));
const code = isEmpty(header['FreightExpense']) ? expense : String(header['FreightExpense']);
const rest = withoutFreight(body);
if (amount > 0 && code !== '') return { body: { ...rest, [FREIGHT_COLLECTION]: [{ ExpenseCode: Number(code), LineTotal: amount }] }, replace: true };
if (mode === 'update' && hadFreight) return { body: { ...rest, [FREIGHT_COLLECTION]: [] }, replace: true };
return { body: rest, replace: false };
}
// --- guardado -----------------------------------------------------------------------------
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
const LINE_FIELDS = ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'] as const;
/** Colección de líneas de un documento y los campos de cada línea que se envían a SAP. */
export interface LinesSpec {
/** `DocumentLines`, o `StockTransferLines` en las transferencias de stock. */
key: string;
fields: readonly string[];
/** Los documentos de ventas y compras referencian el origen de una línea copiada (`BaseType`…). */
base: boolean;
/** Líneas de un documento de servicio: cuenta contable, descripción, impuesto y total, sin artículo ni cantidad. */
service?: boolean;
}
export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true };
/** Referencia al documento origen de una línea copiada; SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no tienen. */
const BASE_FIELDS = ['BaseType', 'BaseEntry', 'BaseLine'] as const;
const hasBase = (l: GridLine): boolean => hasBaseRef(l['BaseType'], l['BaseEntry']);
/**
* Una línea vacía (la fila en blanco que deja la grilla) no se envía. En un documento de artículos es la que no
* tiene artículo; en uno de servicio, la que no tiene cuenta, descripción ni total.
*/
export const isBlankLine = (line: GridLine, service = false): boolean =>
service ? isEmpty(line['AccountCode']) && isEmpty(line['ItemDescription']) && num(line['LineTotal']) === 0 : isEmpty(line['ItemCode']);
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] {
return lines
.filter((l) => !isBlankLine(l, spec.service))
.map((l) => {
const out: DocRecord = {};
for (const f of spec.fields) {
if (DETAIL_KEYS.includes(f)) {
const rows = detailRowsPayload(f, l[f]);
if (rows.length) out[f] = rows;
continue;
}
if (isEmpty(l[f])) continue;
if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla
out[f] = l[f];
}
// Precio con y sin impuesto: solo se envía el que el usuario escribió (el otro lo calcula SAP).
if ('PriceAfterVAT' in out || spec.fields.includes('PriceAfterVAT')) {
if (l['_priceBasis'] === 'gross') delete out['Price'];
else delete out['PriceAfterVAT'];
}
if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
return out;
});
}
/** Un campo obligatorio y su nombre para el aviso. */
export interface RequiredField {
key: string;
label: string;
}
/** Validación de los documentos sin socio de negocio (inventario, solicitudes de compra). */
export function validateLayout(
header: DocRecord,
lines: readonly GridLine[],
requiredHeader: readonly RequiredField[],
requiredLine: readonly RequiredField[],
service = false,
): string[] {
const errors: string[] = [];
for (const f of requiredHeader) if (isEmpty(header[f.key])) errors.push(`Indique ${f.label.toLowerCase()}.`);
const real = lines.filter((l) => !isBlankLine(l, service));
if (!real.length) errors.push(service ? 'Agregue al menos una línea con cuenta contable.' : 'Agregue al menos una línea con artículo.');
real.forEach((l, i) => {
if (service) {
if (isEmpty(l['AccountCode'])) errors.push(`Línea ${i + 1}: indique la cuenta contable.`);
if (num(l['LineTotal']) <= 0) errors.push(`Línea ${i + 1}: el total debe ser mayor que cero.`);
return;
}
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
for (const f of requiredLine) if (isEmpty(l[f.key])) errors.push(`Línea ${i + 1}: indique ${f.label.toLowerCase()}.`);
});
return errors;
}
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
export function createPayload(header: DocRecord, lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord {
return { ...compact(header), [spec.key]: linesPayload(lines, spec) };
}
/**
* Cuerpo para modificar: solo los campos de cabecera que cambiaron (los vaciados se envían como
* `null` para que SAP los limpie) y, si cambiaron, todas las líneas (se envían con
* `B1S-ReplaceCollectionsOnPatch: true`, que reemplaza la colección entera).
*/
export function updatePayload(
original: DocRecord,
header: DocRecord,
originalLines: readonly GridLine[],
lines: readonly GridLine[],
spec: LinesSpec = MARKETING_LINES,
): { body: DocRecord; replaceLines: boolean } {
const body: DocRecord = {};
for (const [key, value] of Object.entries(header)) {
const before = original[key];
if (isEmpty(value) && isEmpty(before)) continue;
if (value !== before) body[key] = isEmpty(value) ? null : value;
}
const next = linesPayload(lines, spec);
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines, spec));
if (replaceLines) body[spec.key] = next;
return { body, replaceLines };
}
/** Quita los campos vacíos (SAP rechaza `""` en campos con lista de valores válidos). */
export function compact(record: DocRecord): DocRecord {
return Object.fromEntries(Object.entries(record).filter(([, v]) => !isEmpty(v)));
}
// --- navegación ---------------------------------------------------------------------------
export type NavTarget = 'first' | 'prev' | 'next' | 'last';
/** Consulta de un solo documento para ir al primero / anterior / siguiente / último por `DocEntry` (u otra clave numérica). */
export function navigationQuery(target: NavTarget, docEntry: number | null, key = 'DocEntry'): { $filter?: string; $orderby: string } {
const asc = `${key} asc`;
const desc = `${key} desc`;
switch (target) {
case 'first':
return { $orderby: asc };
case 'last':
return { $orderby: desc };
case 'prev':
return docEntry === null ? { $orderby: desc } : { $filter: `${key} lt ${docEntry}`, $orderby: desc };
case 'next':
return docEntry === null ? { $orderby: asc } : { $filter: `${key} gt ${docEntry}`, $orderby: asc };
}
}