diff --git a/CLAUDE.md b/CLAUDE.md index 6eba8d6..0b102b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`). Pendiente, en orden: -1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo. +1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla de `document-screens.ts` declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo. 2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible". 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). 4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs. diff --git a/apps/web/src/app/features/documents/document-folio.spec.ts b/apps/web/src/app/features/documents/document-folio.spec.ts index 6d20714..c497812 100644 --- a/apps/web/src/app/features/documents/document-folio.spec.ts +++ b/apps/web/src/app/features/documents/document-folio.spec.ts @@ -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 = {}) => ({ @@ -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(), ); diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index 74965be..a8ff1c2 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -9,136 +9,44 @@ - @if (partner) { -
-
- - - - -
-
- - - - @if (folio) { - - - - - } -
+
+
+ @for (f of layout.header.left; track $index) { + + }
- - - -
-
- -
-
- - - - -
+
+ @for (f of layout.header.right; track $index) { + + }
- } @else if (layout; as lay) { -
-
- @for (f of lay.left; track f.key) { - - } -
-
- - @for (f of lay.right; track f.key) { - - } -
+
+ + + +
+
+ @for (f of layout.footer.left; track $index) { + + }
- - - -
-
- -
+
+ @for (f of layout.footer.right; track $index) { + + }
- } @else { -

El formulario de este documento aún no está disponible. Volver a la lista

- } +
- @if ((partner || layout) && !readOnly()) { + @if (!readOnly()) { @@ -171,13 +79,16 @@ @switch (f.type) { - @case ('date') { - } @case ('warehouse') { @@ -191,13 +102,59 @@ resolveOnLeave /> } - @default { - + } + @case ('number') { + + } + @case ('textarea') { + } + @case ('folio') { + + + + + } + @case ('computed') { + + } + @default { + + } } diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts index 509cb29..42f236b 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -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 => { @@ -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 { 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, diff --git a/apps/web/src/app/features/documents/document-layouts.spec.ts b/apps/web/src/app/features/documents/document-layouts.spec.ts index 495e64a..8930c54 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -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']); } }); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 47c5dae..88bebd8 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -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; + +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'); diff --git a/apps/web/src/app/features/documents/document-model.spec.ts b/apps/web/src/app/features/documents/document-model.spec.ts index 5cad0d9..74ac09f 100644 --- a/apps/web/src/app/features/documents/document-model.spec.ts +++ b/apps/web/src/app/features/documents/document-model.spec.ts @@ -8,7 +8,7 @@ import { navigationQuery, taxRateMap, updatePayload, - validateDocument, + validateLayout, } from './document-model'; const line = (over: Record = {}) => ({ @@ -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.', ]); }); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index ba9ef85..e63f0ec 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -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; diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts index 20d0e4b..f63598e 100644 --- a/apps/web/src/app/features/documents/document-screens.ts +++ b/apps/web/src/app/features/documents/document-screens.ts @@ -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 = 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));