diff --git a/CLAUDE.md b/CLAUDE.md index 831773f..6eba8d6 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. 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. 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 new file mode 100644 index 0000000..6d20714 --- /dev/null +++ b/apps/web/src/app/features/documents/document-folio.spec.ts @@ -0,0 +1,120 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +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 { DocumentFormPage } from './document-form.page'; +import { DOCUMENT_SCREENS } from './document-screens'; + +const invoice = (over: Record = {}) => ({ + 'odata.etag': 'W/"5"', + DocEntry: 8, + DocNum: 108, + CardCode: 'P1', + CardName: 'Proveedor Uno', + FederalTaxID: '76543210-K', + NumAtCard: 'REF-1', + FolioPrefixString: 'F001', + FolioNumber: 198837, + DocDate: '2026-09-11T00:00:00Z', + DocDueDate: '2026-09-20T00:00:00Z', + DiscountPercent: 0, + Comments: '', + DocumentStatus: 'bost_Open', + Cancelled: 'tNO', + DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 10, DiscountPercent: 0, TaxCode: 'IVA', LineTotal: 10, WarehouseCode: '01' }], + ...over, +}); + +describe('folio y RUT en los formularios', () => { + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + const el = () => fixture.nativeElement as HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const page = () => fixture.componentInstance as any; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim()); + + async function open(resource: string, doc: Record | null) { + const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!; + const params = doc ? { docEntry: '8' } : {}; + const route = { + snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap(params) }, + paramMap: of(convertToParamMap(params)), + }; + await TestBed.configureTestingModule({ + imports: [DocumentFormPage], + providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], + }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [{ Code: 'IVA', Rate: 19 }] }); + if (doc) ctrl.expectOne(`/api/sl/${resource}/8`).flush(doc); + await tick(); + } + + 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); + expect(withFolio.sort()).toEqual( + ['CreditNotes', 'DeliveryNotes', 'Invoices', 'PurchaseCreditNotes', 'PurchaseDeliveryNotes', 'PurchaseInvoices'].sort(), + ); + }); + + it('una factura muestra folio, número de folio, referencia y RUT', async () => { + await open('PurchaseInvoices', invoice()); + expect(labels()).toEqual(expect.arrayContaining(['Folio', 'Nº referencia', 'RUT'])); + const inputs = [...el().querySelectorAll('input')].map((i) => i.value); + expect(inputs).toEqual(expect.arrayContaining(['F001', '198837', 'REF-1', '76543210-K'])); + }); + + it('una orden muestra referencia y RUT pero no el folio', async () => { + await open('Orders', invoice()); + expect(labels()).toEqual(expect.arrayContaining(['Nº referencia', 'RUT'])); + expect(labels()).not.toContain('Folio'); + }); + + it('cambiar el folio envía el número como número', async () => { + await open('Invoices', invoice()); + page().setHeader('FolioPrefixString', 'F002'); + page().setHeader('FolioNumber', '200100'); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'PATCH'); + expect(req.request.body).toEqual({ FolioPrefixString: 'F002', FolioNumber: 200100 }); + req.flush(null); + await tick(); + ctrl.expectOne('/api/sl/Invoices/8').flush(invoice()); + }); + + it('un folio que no es numérico no se envía', async () => { + await open('Invoices', invoice()); + page().setHeader('FolioNumber', '12ab'); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + ctrl.expectNone((r) => r.method === 'PATCH'); + expect(el().querySelector('.status')?.textContent).toContain('folio debe ser numérico'); + }); + + it('un documento nuevo sin folio no lo envía', async () => { + await open('Invoices', null); + page().setHeader('CardCode', 'C1'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record; + expect(body).not.toHaveProperty('FolioNumber'); + expect(body).not.toHaveProperty('FolioPrefixString'); + }); +}); 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 d8e2334..74965be 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -23,6 +23,7 @@ (picked)="onPartner($event)" /> + + @if (folio) { + + + + + } 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 f8385b4..509cb29 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -8,7 +8,7 @@ import { map } from 'rxjs'; import { API_URL, apiErrorMessage } from '../../core/api'; import { formatNumber } from '../../core/format'; import { SlDocumentApi } from '../../core/sl/sl-document'; -import { DateField, LookupField, NumberField, TextField, TextareaField } from '../../shared/form'; +import { DateField, LookupField, NumberField, SboField, TextField, TextInput, TextareaField } from '../../shared/form'; import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types'; import { LinesGrid } from '../../shared/grid/lines-grid'; import type { VhLookup } from '../../shared/value-help/value-help.types'; @@ -35,6 +35,8 @@ import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './ /** 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; const today = (): string => new Date().toISOString().slice(0, 10); const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']); @@ -43,10 +45,19 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => { if (!doc) return blank; return Object.fromEntries( - keys.map((k) => [k, DATE_KEYS.has(k) ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : ''))]), + keys.map((k) => [ + k, + DATE_KEYS.has(k) ? dateOnly(doc[k]) : k === 'FolioNumber' ? text(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')), + ]), ); }; const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); +/** El folio se edita como texto; SAP lo espera como número. */ +const folioAsNumber = (h: DocRecord): DocRecord => { + const folio = h['FolioNumber']; + if (typeof folio !== 'string') return h; + return { ...h, FolioNumber: folio.trim() === '' ? null : Number(folio) }; +}; /** * Formulario de un documento de ventas o compras: `nuevo` (vacío) o `:docEntry` (ver / editar). @@ -56,7 +67,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin @Component({ selector: 'sbo-document-form-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp], + imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], templateUrl: './document-form.page.html', styles: ` h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } @@ -76,7 +87,12 @@ export class DocumentFormPage { /** Formulario de inventario o de solicitud de compra (sin socio ni totales). */ protected readonly layout = this.screen.layout; private readonly lineSpec = this.layout?.lines ?? MARKETING_LINES; - private readonly headerKeys: readonly string[] = this.layout ? headerKeysOf(this.layout) : HEADER_KEYS; + 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 }; @@ -115,6 +131,8 @@ export class DocumentFormPage { protected readonly header = linkedSignal(() => this.pick(this.doc())); protected readonly lines = linkedSignal(() => this.docLines(this.doc()).map((l) => ({ ...l }))); protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName'])); + /** RUT del socio: lo completa SAP desde el maestro; aquí solo se muestra. */ + protected readonly partnerTaxId = linkedSignal(() => text(this.doc()?.['FederalTaxID'])); // --- estado -------------------------------------------------------------------------------- @@ -233,6 +251,7 @@ export class DocumentFormPage { if (!card) { this.header.update((h) => ({ ...h, ...copied.header })); this.partnerName.set(text(doc['CardName'])); + this.partnerTaxId.set(text(doc['FederalTaxID'])); } this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l)), ...copied.lines]); this.message.set({ @@ -262,6 +281,7 @@ export class DocumentFormPage { { key: 'CardCode', title: 'Código', width: '130px', mono: true }, { key: 'CardName', title: 'Nombre' }, ], + extraFields: ['FederalTaxID'], }; protected str = text; @@ -280,6 +300,7 @@ export class DocumentFormPage { if (code === text(this.header()['CardCode'])) return; this.setHeader('CardCode', code); this.partnerName.set(''); + this.partnerTaxId.set(''); } protected notify(textMessage: string): void { @@ -288,6 +309,7 @@ export class DocumentFormPage { protected onPartner(row: Record): void { this.partnerName.set(text(row['CardName'])); + this.partnerTaxId.set(text(row['FederalTaxID'])); } protected setLines(lines: readonly GridLine[]): void { @@ -321,6 +343,7 @@ export class DocumentFormPage { 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.'); if (errors.length) { this.message.set({ kind: 'err', text: errors[0] }); return; @@ -330,7 +353,7 @@ export class DocumentFormPage { try { const d = this.doc(); if (!d) { - const header = 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, @@ -338,7 +361,9 @@ export class DocumentFormPage { }); return; } - const { body, replaceLines } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec); + const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec); + const body = folioAsNumber(update.body); + const replaceLines = update.replaceLines; if (!Object.keys(body).length) { this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' }); return; diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts index 3ec0bb1..20d0e4b 100644 --- a/apps/web/src/app/features/documents/document-screens.ts +++ b/apps/web/src/app/features/documents/document-screens.ts @@ -24,6 +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; } @@ -165,7 +167,17 @@ const inventory = ( filters: withStatus ? [statusFilter, dateFilter] : [dateFilter], }); -export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [ +/** 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[] = [ // Ventas marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'), marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'), @@ -202,3 +214,5 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [ 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)); diff --git a/apps/web/src/app/shared/form/lookup-field.ts b/apps/web/src/app/shared/form/lookup-field.ts index 419e22a..e77462d 100644 --- a/apps/web/src/app/shared/form/lookup-field.ts +++ b/apps/web/src/app/shared/form/lookup-field.ts @@ -62,6 +62,7 @@ import { SboField } from './sbo-field'; [columns]="lookup().columns" [baseFilter]="lookup().baseFilter" [orderby]="lookup().orderby" + [extraFields]="lookup().extraFields ?? []" (selected)="onPicked($event)" (closed)="closeList()" />