diff --git a/CLAUDE.md b/CLAUDE.md index 37c503b..ed5d8ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Monorepo npm workspaces: ## Criterios de la interfaz -Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~287 kB, 81 kB comprimido: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). +Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). ## Comandos @@ -60,7 +60,7 @@ Pendiente, en orden: **Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). -**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. 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. +**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. 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. Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (``). Falta: botón PDF. 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/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index b2235ed..00f41d9 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -74,6 +74,8 @@ export const SL_RESOURCES: readonly SlResource[] = [ catalog('SalesPersons', 'number'), catalog('PriceLists', 'number'), catalog('UnitOfMeasurements', 'number'), + // Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto. + catalog('AdditionalExpenses', 'number'), ]; export const SL_RESOURCE_MAP: ReadonlyMap = new Map(SL_RESOURCES.map((r) => [r.name, r])); diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index f947daf..4e63ecd 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types'; import { describe, expect, it } from 'vitest'; import { routes } from './app.routes'; import { permissionGuard } from './core/auth/guards'; +import { DOCUMENT_PATHS } from './features/documents/document-paths'; import { DOCUMENT_SCREENS } from './features/documents/document-screens'; const shell = routes.find((r) => r.path === '')!; @@ -21,7 +22,19 @@ describe('rutas', () => { for (const screen of DOCUMENT_SCREENS) { const route = children.find((r) => r.path === screen.path.slice(1))!; expect(route.children?.map((c) => c.path), screen.path).toEqual(['', 'nuevo', ':docEntry']); - expect(route.data?.['screen']).toBe(screen); + expect(route.resolve?.['screen'], screen.path).toBeDefined(); // la configuración se carga al abrir la pantalla + } + }); + + it('la lista de rutas de documento del enrutador coincide con las pantallas', () => { + expect([...DOCUMENT_PATHS].sort()).toEqual(DOCUMENT_SCREENS.map((s) => s.path).sort()); + }); + + it('el resolver entrega la pantalla de su ruta', async () => { + for (const path of ['/ventas/ordenes', '/inventario/entradas']) { + const route = children.find((r) => r.path === path.slice(1))!; + const screen = await (route.resolve!['screen'] as () => Promise<{ path: string }>)(); + expect(screen.path).toBe(path); } }); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 0399cd0..a892fda 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -2,18 +2,24 @@ import { isDevMode } from '@angular/core'; import type { Route, Routes } from '@angular/router'; import { MENU } from '@basic-erp/sap-types'; import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards'; -import { DOCUMENT_SCREENS, menuLabel } from './features/documents/document-screens'; +import { DOCUMENT_PATHS } from './features/documents/document-paths'; + +const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path; /** * Pantallas de documento: la ruta del menú muestra la lista de los últimos documentos; * `nuevo` abre el formulario vacío y `:docEntry` el documento para ver o editar. * El permiso de menú gobierna las tres (el guard del padre; `data` se hereda en los hijos). + * La configuración de la pantalla (campos, columnas) se carga en diferido: no va en el bundle inicial. */ -const documentRoutes: Routes = DOCUMENT_SCREENS.map( - (screen): Route => ({ - path: screen.path.slice(1), +const documentRoutes: Routes = DOCUMENT_PATHS.map( + (path): Route => ({ + path: path.slice(1), canActivate: [permissionGuard], - data: { path: screen.path, title: menuLabel(screen.path), screen }, + data: { path, title: menuLabel(path) }, + resolve: { + screen: () => import('./features/documents/document-screens').then((m) => m.DOCUMENT_SCREENS.find((s) => s.path === path)!), + }, children: [ { path: '', @@ -54,7 +60,7 @@ const adminRoutes: Routes = [ ]; /** Resto de pantallas del menú (maestros, etc.): aún sin construir. */ -const builtPaths = new Set([...DOCUMENT_SCREENS.map((s) => s.path), '/admin/permisos']); +const builtPaths = new Set([...DOCUMENT_PATHS, '/admin/permisos']); const pendingRoutes: Routes = MENU.flatMap((m) => m.items) .filter((item) => !builtPaths.has(item.path)) .map( 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 a584722..04e180e 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -1,4 +1,4 @@ - + Lista Nuevo @@ -156,6 +156,26 @@ /> } + @case ('freight') { + + + + + } @case ('computed') { } diff --git a/apps/web/src/app/features/documents/document-form.page.spec.ts b/apps/web/src/app/features/documents/document-form.page.spec.ts index c2d4d7b..ad185ae 100644 --- a/apps/web/src/app/features/documents/document-form.page.spec.ts +++ b/apps/web/src/app/features/documents/document-form.page.spec.ts @@ -76,7 +76,7 @@ describe('DocumentFormPage', () => { expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto'); expect(el().querySelector('h1')?.textContent).toContain('1041'); expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1); - expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, total: 238 }); + expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, freight: 0, total: 238 }); expect(buttonText()).toContain('Actualizar'); }); 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 b26a5b9..014f91d 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, SboField, SelectField, TextField, TextInput, TextareaField } from '../../shared/form'; +import { DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, 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'; @@ -19,6 +19,8 @@ import { type DocRecord, type NavTarget, computeTotals, + freightOf, + freightPayload, createPayload, isBlankLine, isDocumentReadOnly, @@ -26,7 +28,7 @@ import { updatePayload, validateLayout, } from './document-model'; -import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, headerKeysOf, requiredHeaderOf } from './document-layouts'; +import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, 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 } from './line-columns'; @@ -60,7 +62,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => { @Component({ selector: 'sbo-document-form-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SelectField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], + imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp], templateUrl: './document-form.page.html', styles: ` h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } @@ -83,8 +85,12 @@ export class DocumentFormPage { private readonly headerKeys: readonly string[] = headerKeysOf(this.layout); private readonly hasFolio = hasFolio(this.layout); private readonly blankHeader: DocRecord = this.layout.newHeader(); + private readonly hasFreight = hasFreight(this.layout); private pick(doc: DocRecord | undefined): DocRecord { - return pickHeader(doc, this.headerKeys, this.blankHeader); + const header = pickHeader(doc, this.headerKeys, this.blankHeader); + if (!doc || !this.hasFreight) return header; + const freight = freightOf(doc); + return { ...header, FreightExpense: freight.expense, FreightAmount: freight.amount }; } private docLines(doc: DocRecord | undefined): GridLine[] { return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? []; @@ -107,6 +113,23 @@ export class DocumentFormPage { : undefined, ); + /** Gastos adicionales de SAP (el flete se registra con uno de ellos). */ + private readonly expensesResource = httpResource>(() => + this.hasFreight ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 100 } } : undefined, + ); + protected readonly expenseOptions = computed(() => + (this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => ({ + value: String(e['ExpensCode']), + label: text(e['Name']) || String(e['ExpensCode']), + })), + ); + /** Gasto que se usa si el usuario no elige: el que se llame "flete" o, si no hay, el primero. */ + protected readonly defaultExpense = computed(() => { + const options = this.expenseOptions(); + return (options.find((o) => /flete|freight/i.test(o.label)) ?? options[0])?.value ?? ''; + }); + protected readonly freightEditable = computed(() => freightOf(this.doc()).editable); + protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined)); protected readonly loading = computed(() => this.docResource.isLoading()); protected readonly loadError = computed(() => { @@ -170,7 +193,7 @@ export class DocumentFormPage { taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []), ); protected readonly totals = computed(() => - computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0)), + computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), Number(this.header()['FreightAmount'] ?? 0)), ); protected readonly fmt = (n: number): string => formatNumber(n, 2); @@ -355,7 +378,10 @@ export class DocumentFormPage { const d = this.doc(); if (!d) { 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)); + const body = this.hasFreight + ? freightPayload(createPayload(header, this.lines(), this.lineSpec), this.header(), this.defaultExpense(), 'create').body + : createPayload(header, this.lines(), this.lineSpec); + const created = await this.api.create(this.screen.resource, body); await this.router.navigate(['..', created['DocEntry']], { relativeTo: this.route, state: { created: text(created['DocNum']) }, @@ -363,8 +389,13 @@ export class DocumentFormPage { return; } const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec); - const body = folioAsNumber(update.body); - const replaceLines = update.replaceLines; + let body = folioAsNumber(update.body); + let replaceLines = update.replaceLines; + if (this.hasFreight) { + const freight = freightPayload(body, this.header(), this.defaultExpense(), 'update', freightOf(d).amount > 0); + body = freight.body; + replaceLines = replaceLines || freight.replace; + } 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-freight.spec.ts b/apps/web/src/app/features/documents/document-freight.spec.ts new file mode 100644 index 0000000..0dfba64 --- /dev/null +++ b/apps/web/src/app/features/documents/document-freight.spec.ts @@ -0,0 +1,130 @@ +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 EXPENSES = { value: [{ ExpensCode: 1, Name: 'Seguro' }, { ExpensCode: 2, Name: 'Flete' }] }; + +describe('flete en el formulario', () => { + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + let navigate: ReturnType; + 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(); + }; + + async function open(resource: string, doc: Record | null) { + const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!; + const params = doc ? { docEntry: '9' } : {}; + 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); + navigate = 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 }] }); + ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush(EXPENSES); + if (doc) ctrl.expectOne(`/api/sl/${resource}/9`).flush(doc); + await tick(); + } + + const order = (rows: Record[]) => ({ + 'odata.etag': 'W/"1"', + DocEntry: 9, + DocNum: 9, + CardCode: 'C1', + CardName: 'ACME', + DocDate: '2026-09-11T00:00:00Z', + DocDueDate: '2026-09-20T00:00:00Z', + DiscountPercent: 0, + DocumentStatus: 'bost_Open', + Cancelled: 'tNO', + DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 100, DiscountPercent: 0, TaxCode: 'IVA', LineTotal: 100, WarehouseCode: '01' }], + DocumentAdditionalExpenses: rows, + }); + + beforeEach(() => TestBed.resetTestingModule()); + + it('muestra la fila Flete con el gasto y el importe que trae SAP, y lo suma al total', async () => { + await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }])); + expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toContain('Flete'); + expect(el().querySelector('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2'); + expect(page().totals()).toMatchObject({ freight: 25, total: 144 }); + }); + + it('un documento sin flete usa por defecto el gasto llamado flete', async () => { + await open('Orders', order([])); + expect(page().defaultExpense()).toBe('2'); + expect(el().querySelector('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2'); + }); + + it('crea el documento con DocumentAdditionalExpenses', async () => { + await open('Orders', null); + page().setHeader('CardCode', 'C1'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]); + page().setHeader('FreightAmount', 1500); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'POST'); + const body = req.request.body as Record; + expect(body['DocumentAdditionalExpenses']).toEqual([{ ExpenseCode: 2, LineTotal: 1500 }]); + expect(body).not.toHaveProperty('FreightAmount'); + expect(body).not.toHaveProperty('FreightExpense'); + req.flush({ DocEntry: 10, DocNum: 10 }); + await tick(); + expect(navigate).toHaveBeenCalled(); + }); + + it('un documento sin flete no envía la colección', async () => { + await open('Orders', null); + page().setHeader('CardCode', 'C1'); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]); + 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('DocumentAdditionalExpenses'); + }); + + it('al cambiar el importe envía la colección reemplazándola', async () => { + await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }])); + page().setHeader('FreightAmount', 40); + 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({ DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 40 }] }); + expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true'); + req.flush(null); + await tick(); + ctrl.expectOne('/api/sl/Orders/9').flush(order([{ ExpenseCode: 2, LineTotal: 40 }])); + }); + + it('con varios gastos adicionales en SAP el flete solo se muestra, no se edita', async () => { + await open('Orders', order([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }])); + expect(page().freightEditable()).toBe(false); + expect(page().totals().freight).toBe(15); + expect(page().dirty()).toBe(false); + }); + + it('las entradas de inventario no tienen flete ni piden gastos adicionales', async () => { + const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'InventoryGenEntries')!; + expect(JSON.stringify(screen.layout.footer)).not.toContain('freight'); + }); +}); 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 87f5c58..e017ae7 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -226,7 +226,7 @@ describe('columnas de línea por documento', () => { it('un documento puede traer su cabecera completa en lugar de la base', () => { const l = marketingLayout({ ...base, header: { left: [], right: [] } }); - expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent']); + expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'FreightExpense', 'FreightAmount']); }); it('los demás documentos no cambian cuando uno elige columnas', () => { diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 7a5869c..f3adf40 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -24,6 +24,8 @@ export type HeaderField = | { 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 } + /** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */ + | { type: 'freight'; label: string } | { type: 'computed'; label: string; source: ComputedSource }; /** Las listas de valores (ventanas modales) de un formulario. */ @@ -170,6 +172,8 @@ export function marketingLayout(opts: { lookups?: LookupColumns; /** Unidad de medida que se toma del artículo: la de venta o la de compra. */ uom: 'sales' | 'purchase'; + /** Fila del flete en el pie (por defecto sí). */ + freight?: boolean; }): DocumentLayout { const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) => c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : c, @@ -191,6 +195,7 @@ export function marketingLayout(opts: { right: [ { type: 'computed', label: 'Total antes del descuento', source: 'subtotal' }, { type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 }, + ...(opts.freight === false ? [] : [{ type: 'freight', label: 'Flete' } as const]), { type: 'computed', label: 'Impuesto', source: 'tax' }, { type: 'computed', label: 'Total del documento', source: 'total' }, ], @@ -219,6 +224,8 @@ export const headerKeysOf = (layout: DocumentLayout): readonly string[] => switch (f.type) { case 'computed': return []; + case 'freight': + return ['FreightExpense', 'FreightAmount']; case 'folio': return [f.prefixKey, f.numberKey]; default: @@ -234,4 +241,6 @@ export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: : [], ); +export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight'); + 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 74ac09f..f6100ae 100644 --- a/apps/web/src/app/features/documents/document-model.spec.ts +++ b/apps/web/src/app/features/documents/document-model.spec.ts @@ -9,6 +9,8 @@ import { taxRateMap, updatePayload, validateLayout, + freightOf, + freightPayload, } from './document-model'; const line = (over: Record = {}) => ({ @@ -35,12 +37,12 @@ describe('computeTotals', () => { it('suma líneas e impuesto por tasa', () => { const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0); - expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, total: 338 }); + expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, freight: 0, total: 338 }); }); it('aplica el descuento de cabecera antes del impuesto', () => { const t = computeTotals([line()], rates, 10); - expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, total: 214.2 }); + expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, freight: 0, total: 214.2 }); }); it('un código de impuesto desconocido no suma impuesto', () => { @@ -48,7 +50,7 @@ describe('computeTotals', () => { }); it('sin líneas, todo en cero', () => { - expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, total: 0 }); + expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, freight: 0, total: 0 }); }); }); @@ -151,3 +153,40 @@ describe('navigationQuery', () => { expect(navigationQuery('next', null)).toEqual({ $orderby: 'DocEntry asc' }); }); }); + +describe('flete (gastos adicionales)', () => { + const doc = (rows: Record[]) => ({ DocumentAdditionalExpenses: rows }); + + it('suma el flete al total del documento', () => { + const rates = taxRateMap([{ Code: 'IVA', Rate: 19 }]); + const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, 25); + expect(t).toMatchObject({ subtotal: 100, tax: 19, freight: 25, total: 144 }); + }); + + it('lee el flete de SAP: código, importe y si se puede editar', () => { + expect(freightOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }]))).toEqual({ expense: '2', amount: 1500, editable: true }); + expect(freightOf(undefined)).toEqual({ expense: '', amount: 0, editable: true }); + expect(freightOf(doc([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }])).editable).toBe(false); + }); + + it('al crear arma DocumentAdditionalExpenses con el gasto por defecto si no se eligió', () => { + const out = freightPayload({ CardCode: 'C1', FreightAmount: 1500, FreightExpense: '' }, { FreightAmount: 1500, FreightExpense: '' }, '2', 'create'); + expect(out.body).toEqual({ CardCode: 'C1', DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 1500 }] }); + expect(out.replace).toBe(true); + }); + + it('al crear sin importe no envía flete', () => { + const out = freightPayload({ CardCode: 'C1', FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'create'); + expect(out.body).toEqual({ CardCode: 'C1' }); + expect(out.replace).toBe(false); + }); + + it('al modificar solo envía el flete si cambió, y con importe 0 lo quita', () => { + expect(freightPayload({ Comments: 'x' }, { FreightAmount: 10 }, '2', 'update', true)).toEqual({ body: { Comments: 'x' }, replace: false }); + const changed = freightPayload({ FreightAmount: 300 }, { FreightAmount: 300, FreightExpense: '3' }, '2', 'update', true); + expect(changed).toEqual({ body: { DocumentAdditionalExpenses: [{ ExpenseCode: 3, LineTotal: 300 }] }, replace: true }); + const cleared = freightPayload({ FreightAmount: 0 }, { FreightAmount: 0, FreightExpense: '3' }, '2', 'update', true); + expect(cleared).toEqual({ body: { DocumentAdditionalExpenses: [] }, replace: true }); + expect(freightPayload({ FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'update', false).body).toEqual({}); + }); +}); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index 782aef9..110a2c7 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -20,7 +20,12 @@ export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean { // --- totales ------------------------------------------------------------------------------ +/** Gasto adicional del documento (el flete): colección `DocumentAdditionalExpenses` de SAP. */ +export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses'; + export interface DocTotals { + /** Flete (gastos adicionales) antes de impuestos. */ + freight: number; /** Suma de los totales de línea (ya con el descuento de cada línea). */ subtotal: number; /** Descuento del documento (% de cabecera aplicado al subtotal). */ @@ -44,6 +49,7 @@ export function computeTotals( lines: readonly GridLine[], rates: ReadonlyMap, discountPercent: number, + freight = 0, ): DocTotals { const factor = 1 - discountPercent / 100; let subtotal = 0; @@ -59,10 +65,50 @@ export function computeTotals( discount: round2(subtotal - net), net: round2(net), tax: round2(tax), - total: round2(net + tax), + freight: round2(freight), + total: round2(net + tax + freight), }; } +// --- flete --------------------------------------------------------------------------------- + +/** Flete del documento tal como se edita: el código del gasto y el importe (`FreightExpense`, `FreightAmount`). */ +export function freightOf(doc: DocRecord | undefined): { expense: string; amount: number; editable: boolean } { + const rows = (doc?.[FREIGHT_COLLECTION] as DocRecord[] | undefined) ?? []; + return { + expense: rows[0]?.['ExpenseCode'] === undefined || rows[0]?.['ExpenseCode'] === null ? '' : String(rows[0]['ExpenseCode']), + amount: round2(rows.reduce((sum, r) => sum + num(r['LineTotal']), 0)), + // con varios gastos adicionales (cargados en SAP) el flete solo se muestra: no se puede reducir a una fila + editable: rows.length <= 1, + }; +} + +const FREIGHT_KEYS = ['FreightExpense', 'FreightAmount'] as const; +const withoutFreight = (r: DocRecord): DocRecord => Object.fromEntries(Object.entries(r).filter(([k]) => !(FREIGHT_KEYS as readonly string[]).includes(k))); + +/** + * Convierte los campos del flete del formulario (`FreightExpense`, `FreightAmount`) en la colección + * `DocumentAdditionalExpenses` que espera SAP. `expense` es el código elegido o, si no eligió, el + * de flete por defecto. Al crear, sin importe no se envía nada; al modificar solo si cambió (con + * `replace`, porque SAP reemplaza la colección entera) y un importe 0 quita el flete. + */ +export function freightPayload( + body: DocRecord, + header: DocRecord, + expense: string, + mode: 'create' | 'update', + hadFreight = false, +): { body: DocRecord; replace: boolean } { + const touched = FREIGHT_KEYS.some((k) => k in body); + if (mode === 'update' && !touched) return { body, replace: false }; + const amount = round2(num(header['FreightAmount'])); + const code = isEmpty(header['FreightExpense']) ? expense : String(header['FreightExpense']); + const rest = withoutFreight(body); + if (amount > 0 && code !== '') return { body: { ...rest, [FREIGHT_COLLECTION]: [{ ExpenseCode: Number(code), LineTotal: amount }] }, replace: true }; + if (mode === 'update' && hadFreight) return { body: { ...rest, [FREIGHT_COLLECTION]: [] }, replace: true }; + return { body: rest, replace: false }; +} + // --- guardado ----------------------------------------------------------------------------- /** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */ diff --git a/apps/web/src/app/features/documents/document-paths.ts b/apps/web/src/app/features/documents/document-paths.ts new file mode 100644 index 0000000..2f00eb2 --- /dev/null +++ b/apps/web/src/app/features/documents/document-paths.ts @@ -0,0 +1,27 @@ +/** + * Rutas de menú que son pantallas de documento. Es la única parte que el enrutador necesita al + * arrancar; la configuración completa de cada pantalla (`document-screens.ts`) se carga en + * diferido al abrir una. Un test comprueba que esta lista coincide con `DOCUMENT_SCREENS`. + */ +export const DOCUMENT_PATHS: readonly string[] = [ + // Ventas + '/ventas/ofertas', + '/ventas/ordenes', + '/ventas/entrega', + '/ventas/facturas', + '/ventas/devoluciones', + '/ventas/notas-credito', + // Compras + '/compras/solicitudes', + '/compras/ofertas', + '/compras/ordenes', + '/compras/recepcion', + '/compras/facturas', + '/compras/devoluciones', + '/compras/notas-credito', + // Inventario + '/inventario/entradas', + '/inventario/salidas', + '/inventario/transferencias', + '/inventario/solicitudes-traslado', +]; diff --git a/apps/web/src/app/shared/window/toolbar.ts b/apps/web/src/app/shared/window/toolbar.ts index 5dd7a14..1983bce 100644 --- a/apps/web/src/app/shared/window/toolbar.ts +++ b/apps/web/src/app/shared/window/toolbar.ts @@ -1,13 +1,16 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, booleanAttribute, input } from '@angular/core'; /** * Barra de herramientas del documento (Nueva, Buscar, navegación...). Los botones son * `