feat(web): lógica pura del documento (totales, payload, solo lectura, navegación)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c66ad96e4a
commit
51982fd3ab
2 files changed
+284
No files matched your search
@@ -0,0 +1,151 @@
|
||||
import type { GridLine } from '../../shared/grid/grid.types';
|
||||
|
||||
/** 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 ------------------------------------------------------------------------------
|
||||
|
||||
export interface DocTotals {
|
||||
/** 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,
|
||||
): 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),
|
||||
total: round2(net + tax),
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
/** Una línea vacía (la fila en blanco que deja la grilla) no se envía. */
|
||||
export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']);
|
||||
|
||||
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
|
||||
export function linesPayload(lines: readonly GridLine[]): DocRecord[] {
|
||||
return lines
|
||||
.filter((l) => !isBlankLine(l))
|
||||
.map((l) => {
|
||||
const out: DocRecord = {};
|
||||
for (const f of LINE_FIELDS) if (!isEmpty(l[f])) out[f] = l[f];
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/** Errores que se pueden detectar sin preguntarle a SAP. Lista vacía = se puede guardar. */
|
||||
export function validateDocument(header: DocRecord, lines: readonly GridLine[], partnerLabel: string): string[] {
|
||||
const errors: string[] = [];
|
||||
if (isEmpty(header['CardCode'])) errors.push(`Indique el ${partnerLabel.toLowerCase()}.`);
|
||||
if (isEmpty(header['DocDate'])) errors.push('Indique la fecha de contabilización.');
|
||||
const real = lines.filter((l) => !isBlankLine(l));
|
||||
if (!real.length) errors.push('Agregue al menos una línea con artículo.');
|
||||
real.forEach((l, i) => {
|
||||
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
|
||||
export function createPayload(header: DocRecord, lines: readonly GridLine[]): DocRecord {
|
||||
return { ...compact(header), DocumentLines: linesPayload(lines) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[],
|
||||
): { 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);
|
||||
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines));
|
||||
if (replaceLines) body['DocumentLines'] = 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`. */
|
||||
export function navigationQuery(target: NavTarget, docEntry: number | null): { $filter?: string; $orderby: string } {
|
||||
switch (target) {
|
||||
case 'first':
|
||||
return { $orderby: 'DocEntry asc' };
|
||||
case 'last':
|
||||
return { $orderby: 'DocEntry desc' };
|
||||
case 'prev':
|
||||
return docEntry === null
|
||||
? { $orderby: 'DocEntry desc' }
|
||||
: { $filter: `DocEntry lt ${docEntry}`, $orderby: 'DocEntry desc' };
|
||||
case 'next':
|
||||
return docEntry === null
|
||||
? { $orderby: 'DocEntry asc' }
|
||||
: { $filter: `DocEntry gt ${docEntry}`, $orderby: 'DocEntry asc' };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user