refactor(web): el formulario base recibe de cada pantalla los campos de cabecera, pie y línea

Todas las pantallas declaran su DocumentLayout (catálogo FIELDS): ventas y compras dejan de tener la
cabecera escrita en el HTML; el folio solo lo llevan los documentos tributarios.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 15:10:40 -05:00
1 parent c734dcbd0a
commit 8b0b8afc87
9 files changed
+320 -257

No files matched your search

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angul
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentFormPage } from './document-form.page';
import { hasFolio } from './document-layouts';
import { DOCUMENT_SCREENS } from './document-screens';
const invoice = (over: Record<string, unknown> = {}) => ({
@@ -63,7 +64,7 @@ describe('folio y RUT en los formularios', () => {
beforeEach(() => TestBed.resetTestingModule());
it('lo llevan facturas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
const withFolio = DOCUMENT_SCREENS.filter((s) => s.folio).map((s) => s.resource);
const withFolio = DOCUMENT_SCREENS.filter((s) => hasFolio(s.layout)).map((s) => s.resource);
expect(withFolio.sort()).toEqual(
['CreditNotes', 'DeliveryNotes', 'Invoices', 'PurchaseCreditNotes', 'PurchaseDeliveryNotes', 'PurchaseInvoices'].sort(),
);
@@ -9,136 +9,44 @@
</sbo-toolbar>
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
@if (partner) {
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-lookup-field
[label]="partner.label"
required
[readonly]="readOnly() || !isNew()"
[value]="str(header()['CardCode'])"
(valueChange)="onPartnerText($event)"
[lookup]="partnerLookup"
resolveOnLeave
(picked)="onPartner($event)"
/>
<sbo-text-field label="Nombre" readonly [value]="partnerName()" />
<sbo-text-field label="RUT" readonly [value]="partnerTaxId()" />
<sbo-text-field
label="Nº referencia"
[readonly]="readOnly()"
[value]="str(header()['NumAtCard'])"
(valueChange)="setHeader('NumAtCard', $event)"
/>
</div>
<div class="sbo-col">
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
<sbo-date-field
label="Fecha de contabilización"
required
[readonly]="readOnly()"
[value]="str(header()['DocDate'])"
(valueChange)="setHeader('DocDate', $event)"
/>
<sbo-date-field
label="Fecha de vencimiento"
[readonly]="readOnly()"
[value]="str(header()['DocDueDate'])"
(valueChange)="setHeader('DocDueDate', $event)"
/>
@if (folio) {
<sbo-field label="Folio">
<sbo-input
width="90px"
[maxlength]="10"
[readonly]="readOnly()"
[value]="str(header()['FolioPrefixString'])"
(valueChange)="setHeader('FolioPrefixString', $event)"
/>
<sbo-input
ariaLabel="Número de folio"
[readonly]="readOnly()"
[value]="str(header()['FolioNumber'])"
(valueChange)="setHeader('FolioNumber', $event)"
/>
</sbo-field>
}
</div>
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of layout.header.left; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
<sbo-lines-grid
[columns]="columns"
[lines]="lines()"
(linesChange)="setLines($event)"
(notice)="notify($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[recalc]="recalc"
[multiAdd]="multi"
/>
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
<sbo-textarea-field
label="Comentarios"
[readonly]="readOnly()"
[value]="str(header()['Comments'])"
(valueChange)="setHeader('Comments', $event)"
/>
</div>
<div class="sbo-col">
<sbo-text-field label="Total antes del descuento" readonly [value]="fmt(totals().subtotal)" />
<sbo-number-field
label="Descuento %"
[decimals]="2"
[readonly]="readOnly()"
[value]="+str(header()['DiscountPercent'])"
(valueChange)="setHeader('DiscountPercent', $event ?? 0)"
/>
<sbo-text-field label="Impuesto" readonly [value]="fmt(totals().tax)" />
<sbo-text-field label="Total del documento" readonly [value]="fmt(totals().total)" />
</div>
<div class="sbo-col">
@for (f of layout.header.right; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
} @else if (layout; as lay) {
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of lay.left; track f.key) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
<div class="sbo-col">
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
@for (f of lay.right; track f.key) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
</div>
<sbo-lines-grid
[columns]="columns"
[lines]="lines()"
(linesChange)="setLines($event)"
(notice)="notify($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[recalc]="recalc"
[multiAdd]="multi"
/>
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
@for (f of layout.footer.left; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
<sbo-lines-grid
[columns]="columns"
[lines]="lines()"
(linesChange)="setLines($event)"
(notice)="notify($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[multiAdd]="multi"
/>
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
<sbo-textarea-field
label="Comentarios"
[readonly]="readOnly()"
[value]="str(header()['Comments'])"
(valueChange)="setHeader('Comments', $event)"
/>
</div>
<div class="sbo-col">
@for (f of layout.footer.right; track $index) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
} @else {
<p>El formulario de este documento aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
}
</div>
@if ((partner || layout) && !readOnly()) {
@if (!readOnly()) {
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
{{ isNew() ? 'Crear' : 'Actualizar' }}
</button>
@@ -171,13 +79,16 @@
<ng-template #field let-f>
@switch (f.type) {
@case ('date') {
<sbo-date-field
@case ('partner') {
<sbo-lookup-field
[label]="f.label"
[required]="!!f.required"
[readonly]="readOnly()"
[readonly]="readOnly() || !isNew()"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
(valueChange)="onPartnerText($event)"
[lookup]="partnerLookup"
resolveOnLeave
(picked)="onPartner($event)"
/>
}
@case ('warehouse') {
@@ -191,13 +102,59 @@
resolveOnLeave
/>
}
@default {
<sbo-text-field
@case ('date') {
<sbo-date-field
[label]="f.label"
[required]="!!f.required"
[readonly]="readOnly()"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
/>
}
@case ('number') {
<sbo-number-field
[label]="f.label"
[decimals]="f.decimals"
[readonly]="readOnly()"
[value]="+str(header()[f.key])"
(valueChange)="setHeader(f.key, $event ?? 0)"
/>
}
@case ('textarea') {
<sbo-textarea-field
[label]="f.label"
[readonly]="readOnly()"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
/>
}
@case ('folio') {
<sbo-field [label]="f.label">
<sbo-input
width="90px"
[maxlength]="10"
[readonly]="readOnly()"
[value]="str(header()[f.prefixKey])"
(valueChange)="setHeader(f.prefixKey, $event)"
/>
<sbo-input
ariaLabel="Número de folio"
[readonly]="readOnly()"
[value]="str(header()[f.numberKey])"
(valueChange)="setHeader(f.numberKey, $event)"
/>
</sbo-field>
}
@case ('computed') {
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
}
@default {
<sbo-text-field
[label]="f.label"
[readonly]="readOnly() || !!f.readonly"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
/>
}
}
</ng-template>
@@ -19,24 +19,17 @@ import {
type DocRecord,
type NavTarget,
computeTotals,
MARKETING_LINES,
createPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateDocument,
validateLayout,
} from './document-model';
import { WAREHOUSE_LOOKUP, headerKeysOf } from './document-layouts';
import { type ComputedSource, WAREHOUSE_LOOKUP, hasFolio, headerKeysOf, requiredHeaderOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
/** Campos de cabecera que se editan en el formulario (el resto lo calcula SAP). */
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
/** Folio tributario de los documentos que lo llevan. */
const FOLIO_KEYS = ['FolioPrefixString', 'FolioNumber'] as const;
import { ITEMS_MULTI_ADD } from './line-columns';
const today = (): string => new Date().toISOString().slice(0, 10);
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
@@ -84,18 +77,12 @@ export class DocumentFormPage {
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly partner = this.screen.partner;
/** Formulario de inventario o de solicitud de compra (sin socio ni totales). */
/** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */
protected readonly layout = this.screen.layout;
private readonly lineSpec = this.layout?.lines ?? MARKETING_LINES;
private readonly headerKeys: readonly string[] = this.layout
? headerKeysOf(this.layout)
: this.screen.folio
? [...HEADER_KEYS, ...FOLIO_KEYS]
: HEADER_KEYS;
protected readonly folio = !!this.screen.folio;
private readonly blankHeader: DocRecord = this.layout
? this.layout.newHeader()
: { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
private readonly lineSpec = this.layout.lines;
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
private pick(doc: DocRecord | undefined): DocRecord {
return pickHeader(doc, this.headerKeys, this.blankHeader);
}
@@ -136,7 +123,7 @@ export class DocumentFormPage {
// --- estado --------------------------------------------------------------------------------
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout?.immutable && !this.isNew()));
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout.immutable && !this.isNew()));
protected readonly saving = signal(false);
private readonly message = signal<StatusMessage | null>(
((): StatusMessage | null => {
@@ -152,11 +139,11 @@ export class DocumentFormPage {
const message = this.message();
if (message) return message;
if (this.readOnly()) {
const posted = this.layout?.immutable && !isDocumentReadOnly(this.doc());
const posted = this.layout.immutable && !isDocumentReadOnly(this.doc());
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
}
if (this.isNew()) {
return { kind: 'info', text: this.layout ? 'Complete la cabecera y las líneas.' : `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
return { kind: 'info', text: this.partner ? `Indique el ${this.partner.label.toLowerCase()} y las líneas.` : 'Complete la cabecera y las líneas.' };
}
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
});
@@ -265,12 +252,13 @@ export class DocumentFormPage {
// --- configuración de la vista --------------------------------------------------------------
protected readonly columns = this.layout?.columns ?? lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
protected readonly newLine = this.layout ? () => this.layout!.newLine(this.header()) : newDocumentLine;
protected readonly recalc = this.layout ? undefined : recalcLine;
protected readonly multi: GridMultiAdd = this.layout
? { ...ITEMS_MULTI_ADD, toLine: (item) => this.layout!.columns[0].lookup!.apply(item, this.layout!.newLine(this.header())) }
: ITEMS_MULTI_ADD;
protected readonly columns = this.layout.columns;
protected readonly newLine = () => this.layout.newLine(this.header());
protected readonly recalc = this.layout.recalc;
protected readonly multi: GridMultiAdd = {
...ITEMS_MULTI_ADD,
toLine: (item) => this.layout.columns[0].lookup!.apply(item, this.layout.newLine(this.header())),
};
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
protected readonly partnerLookup: VhLookup = {
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
@@ -286,6 +274,24 @@ export class DocumentFormPage {
protected str = text;
/** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */
protected computedValue(source: ComputedSource): string {
switch (source) {
case 'docNum':
return text(this.doc()?.['DocNum']);
case 'partnerName':
return this.partnerName();
case 'partnerTaxId':
return this.partnerTaxId();
case 'subtotal':
return this.fmt(this.totals().subtotal);
case 'tax':
return this.fmt(this.totals().tax);
case 'total':
return this.fmt(this.totals().total);
}
}
constructor() {
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
}
@@ -335,15 +341,8 @@ export class DocumentFormPage {
protected async save(): Promise<void> {
if (this.saving() || this.readOnly()) return;
const errors = this.layout
? validateLayout(
this.header(),
this.lines(),
[...this.layout.left, ...this.layout.right].filter((f) => f.required),
this.layout.requiredLine,
)
: validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
if (this.folio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine);
if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
@@ -353,7 +352,7 @@ export class DocumentFormPage {
try {
const d = this.doc();
if (!d) {
const header = folioAsNumber(this.layout?.beforeCreate?.(this.header()) ?? this.header());
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
@@ -4,7 +4,7 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LAYOUTS, headerKeysOf } from './document-layouts';
import { LAYOUTS, hasFolio, headerKeysOf } from './document-layouts';
import { createPayload, updatePayload, validateLayout } from './document-model';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
@@ -12,18 +12,33 @@ import { DOCUMENT_SCREENS } from './document-screens';
describe('modelo de los formularios sin socio', () => {
const transfer = LAYOUTS.transferRequest;
it('cada pantalla de inventario y la solicitud de compra tienen su formulario', () => {
const withLayout = DOCUMENT_SCREENS.filter((s) => s.layout).map((s) => s.resource);
expect(withLayout.sort()).toEqual(
it('cada pantalla tiene su formulario, declarado por ella', () => {
expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true);
const withPartner = DOCUMENT_SCREENS.filter((s) => s.partner).map((s) => s.resource);
const without = DOCUMENT_SCREENS.filter((s) => !s.partner).map((s) => s.resource);
expect(withPartner).toHaveLength(12);
expect(without.sort()).toEqual(
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
);
expect(DOCUMENT_SCREENS.filter((s) => s.layout && s.partner)).toHaveLength(0);
});
it('la cabecera tiene como máximo dos columnas y no usa campos de usuario', () => {
for (const l of Object.values(LAYOUTS)) {
for (const k of headerKeysOf(l)) expect(k.startsWith('U_')).toBe(false);
for (const c of l.columns) expect(c.key.startsWith('U_')).toBe(false);
it('cada formulario declara solo sus campos: la orden de venta no lleva folio y la factura sí', () => {
const by = (r: string) => DOCUMENT_SCREENS.find((s) => s.resource === r)!.layout;
expect(hasFolio(by('Orders'))).toBe(false);
expect(hasFolio(by('PurchaseQuotations'))).toBe(false);
for (const r of ['Invoices', 'PurchaseInvoices', 'CreditNotes', 'PurchaseCreditNotes', 'DeliveryNotes', 'PurchaseDeliveryNotes']) {
expect(hasFolio(by(r))).toBe(true);
}
expect(headerKeysOf(by('Invoices'))).toEqual(expect.arrayContaining(['FolioPrefixString', 'FolioNumber']));
expect(headerKeysOf(by('Orders'))).not.toContain('FolioNumber');
});
it('la cabecera y el pie tienen como máximo dos columnas y no usan campos de usuario', () => {
for (const s of DOCUMENT_SCREENS) {
for (const k of headerKeysOf(s.layout)) expect(k.startsWith('U_')).toBe(false);
for (const c of s.layout.columns) expect(c.key.startsWith('U_')).toBe(false);
expect(Object.keys(s.layout.header)).toEqual(['left', 'right']);
expect(Object.keys(s.layout.footer)).toEqual(['left', 'right']);
}
});
@@ -1,39 +1,79 @@
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { DocRecord, LinesSpec, RequiredField } from './document-model';
import { DOCUMENT_LINE_COLUMNS, uomEntryOf } from './line-columns';
import type { DocRecord, LinesSpec } from './document-model';
import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
export interface HeaderField {
key: string;
label: string;
/** `warehouse` abre la lista de almacenes. */
type: 'text' | 'date' | 'warehouse';
required?: boolean;
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
/**
* Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista
* de campos y el formulario base los dibuja: no hay HTML por documento.
*/
export type HeaderField =
| { type: 'text'; key: string; label: string; readonly?: boolean }
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
| { type: 'number'; key: string; label: string; decimals?: number }
| { type: 'textarea'; key: string; label: string }
/** Abre la lista de almacenes. */
| { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string }
/** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
| { type: 'computed'; label: string; source: ComputedSource };
export interface FieldColumns {
/** Dos columnas como máximo (regla del proyecto base). */
left: readonly HeaderField[];
right: readonly HeaderField[];
}
/**
* Formulario de los documentos sin socio de negocio ni totales (inventario y solicitudes de
* compra): qué campos de cabecera y de línea tienen, y cómo se guardan.
* Formulario de un documento: qué campos de cabecera y de pie, qué columnas de línea y cómo se
* guarda. El formulario base lo dibuja sin saber de qué documento se trata.
*/
export interface DocumentLayout {
lines: LinesSpec;
/** Dos columnas como máximo (regla del proyecto base); el número y los comentarios van aparte. */
left: readonly HeaderField[];
right: readonly HeaderField[];
header: FieldColumns;
/** Debajo de las líneas: comentarios y, donde los hay, descuento y totales. */
footer: FieldColumns;
columns: readonly GridColumn[];
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
newHeader: () => DocRecord;
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
newLine: (header: DocRecord) => GridLine;
/** Recalcula una línea tras un cambio (total = cantidad × precio…). */
recalc?: (line: GridLine, changedKey: string) => GridLine;
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
immutable?: boolean;
/** Ajustes a la cabecera antes de crear. */
beforeCreate?: (header: DocRecord) => DocRecord;
requiredLine: readonly RequiredField[];
requiredLine: readonly { key: string; label: string }[];
}
const today = (): string => new Date().toISOString().slice(0, 10);
// --- catálogo de campos ---------------------------------------------------------------------
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
export const FIELDS = {
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
docDate: { type: 'date', key: 'DocDate', label: 'Fecha de contabilización', required: true, missing: 'la fecha de contabilización' },
docDueDate: { type: 'date', key: 'DocDueDate', label: 'Fecha de vencimiento' },
requiredDate: { type: 'date', key: 'RequriedDate', label: 'Fecha requerida', required: true, missing: 'la fecha requerida' },
comments: { type: 'textarea', key: 'Comments', label: 'Comentarios' },
numAtCard: { type: 'text', key: 'NumAtCard', label: 'Nº referencia' },
folio: { type: 'folio', label: 'Folio', prefixKey: 'FolioPrefixString', numberKey: 'FolioNumber' },
fromWarehouse: { type: 'warehouse', key: 'FromWarehouse', label: 'Almacén origen', required: true, missing: 'el almacén origen' },
toWarehouse: { type: 'warehouse', key: 'ToWarehouse', label: 'Almacén destino', required: true, missing: 'el almacén destino' },
requester: { type: 'text', key: 'Requester', label: 'Solicitante (usuario)' },
} as const satisfies Record<string, HeaderField>;
const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] };
// --- columnas de línea compartidas ----------------------------------------------------------
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
@@ -50,11 +90,55 @@ export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
// --- documentos con socio de negocio (ventas y compras) -------------------------------------
/**
* Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con
* comentarios, descuento y totales. `left` y `right` son los campos que esa pantalla agrega a la
* cabecera (por ejemplo `FIELDS.folio` en las facturas); sin ellos, la cabecera base.
*/
export function marketingLayout(opts: {
partnerLabel: string;
taxResource: string;
left?: readonly HeaderField[];
right?: readonly HeaderField[];
}): DocumentLayout {
return {
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'], base: true },
header: {
left: [
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
FIELDS.numAtCard,
...(opts.left ?? []),
],
right: [FIELDS.docNum, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
},
footer: {
left: [FIELDS.comments],
right: [
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
{ type: 'computed', label: 'Impuesto', source: 'tax' },
{ type: 'computed', label: 'Total del documento', source: 'total' },
],
},
columns: lineColumnsFor(opts.taxResource),
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
newLine: newDocumentLine,
recalc: recalcLine,
requiredLine: [],
};
}
// --- documentos sin socio ni totales (inventario y solicitudes de compra) -------------------
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
const goods: DocumentLayout = {
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
left: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
right: [],
header: { left: [FIELDS.docDate], right: [FIELDS.docNum] },
footer: commentsOnly,
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
newHeader: () => ({ DocDate: today() }),
newLine: () => ({ ...blankLine(), Price: null }),
@@ -65,11 +149,8 @@ const goods: DocumentLayout = {
/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
const transfer = (immutable: boolean): DocumentLayout => ({
lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
left: [
{ key: 'FromWarehouse', label: 'Almacén origen', type: 'warehouse', required: true },
{ key: 'ToWarehouse', label: 'Almacén destino', type: 'warehouse', required: true },
],
right: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] },
footer: commentsOnly,
columns: [
itemColumn,
descriptionColumn,
@@ -99,11 +180,8 @@ const transfer = (immutable: boolean): DocumentLayout => ({
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
const purchaseRequest: DocumentLayout = {
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
left: [{ key: 'Requester', label: 'Solicitante (usuario)', type: 'text' }],
right: [
{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true },
{ key: 'RequriedDate', label: 'Fecha requerida', type: 'date', required: true },
],
header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] },
footer: commentsOnly,
columns: [
itemColumn,
descriptionColumn,
@@ -143,9 +221,34 @@ export const LAYOUTS = {
purchaseRequest,
} as const;
/** Campos de cabecera que se editan (y se envían): los de las columnas del formulario y los comentarios. */
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => [
...layout.left.map((f) => f.key),
...layout.right.map((f) => f.key),
'Comments',
// --- consultas sobre un layout --------------------------------------------------------------
const allFields = (layout: DocumentLayout): HeaderField[] => [
...layout.header.left,
...layout.header.right,
...layout.footer.left,
...layout.footer.right,
];
/** Campos de cabecera que se editan (y se envían a SAP). */
export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
allFields(layout).flatMap((f) => {
switch (f.type) {
case 'computed':
return [];
case 'folio':
return [f.prefixKey, f.numberKey];
default:
return [f.key];
}
});
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] =>
allFields(layout).flatMap((f) =>
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner') && f.required
? [{ key: f.key, label: f.missing ?? f.label }]
: [],
);
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
@@ -8,7 +8,7 @@ import {
navigationQuery,
taxRateMap,
updatePayload,
validateDocument,
validateLayout,
} from './document-model';
const line = (over: Record<string, unknown> = {}) => ({
@@ -52,15 +52,19 @@ describe('computeTotals', () => {
});
});
describe('validateDocument', () => {
describe('validateLayout', () => {
const header = { CardCode: 'C1', DocDate: '2026-01-05' };
const required = [
{ key: 'CardCode', label: 'el cliente' },
{ key: 'DocDate', label: 'la fecha de contabilización' },
];
it('documento correcto no tiene errores', () => {
expect(validateDocument(header, [line()], 'Cliente')).toEqual([]);
expect(validateLayout(header, [line()], required, [])).toEqual([]);
});
it('exige socio, fecha y al menos una línea con artículo', () => {
expect(validateDocument({}, [line({ ItemCode: '' })], 'Cliente')).toEqual([
it('exige los campos obligatorios y al menos una línea con artículo', () => {
expect(validateLayout({}, [line({ ItemCode: '' })], required, [])).toEqual([
'Indique el cliente.',
'Indique la fecha de contabilización.',
'Agregue al menos una línea con artículo.',
@@ -68,7 +72,7 @@ describe('validateDocument', () => {
});
it('exige cantidad mayor que cero (numerando solo las líneas reales)', () => {
expect(validateDocument(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], 'Cliente')).toEqual([
expect(validateLayout(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], required, [])).toEqual([
'Línea 1: la cantidad debe ser mayor que cero.',
]);
});
@@ -97,19 +97,6 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE
});
}
/** 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;
}
/** Un campo obligatorio y su nombre para el aviso. */
export interface RequiredField {
key: string;
@@ -2,7 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
import { formatNumber } from '../../core/format';
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
import { type DocumentLayout, LAYOUTS } from './document-layouts';
import { type DocumentLayout, FIELDS, LAYOUTS, type HeaderField, marketingLayout } from './document-layouts';
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
export interface DocumentScreen {
@@ -24,10 +24,8 @@ export interface DocumentScreen {
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
taxResource: string;
};
/** El formulario muestra el folio tributario (`FolioPrefixString`, `FolioNumber`) y el RUT del socio. */
folio?: boolean;
/** Formulario de los documentos sin socio ni totales (inventario y solicitudes de compra). */
layout?: DocumentLayout;
/** Campos de la cabecera y del pie del formulario, columnas de línea y cómo se guarda. */
layout: DocumentLayout;
}
export const menuLabel = (path: string): string =>
@@ -110,15 +108,26 @@ const currencyFilter: ListFilter = {
labelField: 'Name',
};
/** Campos que una pantalla de ventas o compras agrega a la cabecera base y columnas extra de su lista. */
interface MarketingExtras {
columns?: readonly ListColumn[];
headerLeft?: readonly HeaderField[];
headerRight?: readonly HeaderField[];
}
/** Documentos tributarios: la cabecera suma el folio (en el Service Layer `FolioPrefixString` y `FolioNumber`). */
const TAX_DOCUMENT: MarketingExtras = { headerRight: [FIELDS.folio] };
/** Ventas y compras: documentos con socio de negocio y total. */
const marketing = (
path: string,
resource: string,
createLabel: string,
partner: string,
extraColumns: readonly ListColumn[] = [],
extras: MarketingExtras = {},
): DocumentScreen => ({
path,
layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }),
resource,
createLabel,
orderby: 'DocEntry desc',
@@ -129,7 +138,7 @@ const marketing = (
columns: [
docNum,
statusColumn,
...extraColumns,
...(extras.columns ?? []),
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
{ key: 'CardName', title: 'Nombre' },
docDate,
@@ -167,24 +176,14 @@ const inventory = (
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
});
/** Documentos tributarios: llevan folio y RUT. */
const FOLIO_PATHS: ReadonlySet<string> = new Set([
'/ventas/entrega',
'/ventas/facturas',
'/ventas/notas-credito',
'/compras/recepcion',
'/compras/facturas',
'/compras/notas-credito',
]);
const SCREENS: readonly DocumentScreen[] = [
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
// Ventas
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente'),
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', invoiceColumns),
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT),
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }),
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente'),
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT),
// Compras
{
path: '/compras/solicitudes',
@@ -204,15 +203,13 @@ const SCREENS: readonly DocumentScreen[] = [
},
marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'),
marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'),
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor'),
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', invoiceColumns),
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT),
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }),
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor'),
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT),
// Inventario
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods),
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods),
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
];
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = SCREENS.map((s) => (FOLIO_PATHS.has(s.path) ? { ...s, folio: true } : s));