From 0b988733f66ba32997b0d90ae2f623593b498067 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 13:03:32 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20anticipo=20total,=20importe=20apli?= =?UTF-8?q?cado=20y=20saldo=20pendiente=20en=20el=20pie=20de=20facturas=20?= =?UTF-8?q?y=20notas=20de=20cr=C3=A9dito?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- .../features/documents/compras/compras.screens.ts | 6 +++--- .../app/features/documents/document-expenses.spec.ts | 11 +++++++++++ .../src/app/features/documents/document-form.page.ts | 8 ++++++++ .../src/app/features/documents/document-layouts.ts | 9 ++++++++- apps/web/src/app/features/documents/screen-kit.ts | 6 ++++++ .../app/features/documents/ventas/ventas.screens.ts | 12 ++++++------ 7 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8786b6..8a2f391 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Pendiente, en orden: **Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo). -**Redondeo** (`Rounding`, `RoundingDiffAmount`): fila «Redondeo» (casilla + importe) en el pie de ventas y compras, después de «Gastos adicionales» y antes del impuesto (como en SAP; ver `tools/factura.png`). Los campos siempre existen en el documento (se leen y se conservan); la fila solo se ve si la empresa usa redondeo por moneda (`OADM.RoundMthd`, `RoundingMethod = tYES` en el Service Layer; confirmado con el usuario; por documento no se muestra). Esa bandera y el separador de segmentos de las cuentas salen de `GET /api/company/settings` (backend: `CompanyService`, lee `CompanyService_GetAdminInfo` y entrega solo esos dos campos, con caché de 10 min). El importe solo cuenta con la casilla marcada; sin marcarla no se envía nada y SAP calcula el suyo. **Gastos adicionales** hechos: tabla «Gastos adicionales» en el pie de ventas y compras (una fila por gasto: código con su ventana de selección sobre `AdditionalExpenses`, nombre, importe, impuesto y comentarios; colección `DocumentAdditionalExpenses`). Se suman al total con su propio impuesto (`computeTotals`); al modificar, si cambian las filas se envía la colección completa con `B1S-ReplaceCollectionsOnPatch` (quitarlas todas envía `[]`); `expenses: false` en `marketing(...)` la quita de un documento; las extensiones pueden ocultarla con el nombre `DocumentAdditionalExpenses`. Falta: botón PDF. En ventas un gasto adicional sin cuenta de ingresos falla en SBODemoCL ("Define the freight revenue account…"; configuración de SAP, no del código); en compras funciona (orden de compra 2885). +**Redondeo** (`Rounding`, `RoundingDiffAmount`): fila «Redondeo» (casilla + importe) en el pie de ventas y compras, después de «Gastos adicionales» y antes del impuesto (como en SAP; ver `tools/factura.png`). Los campos siempre existen en el documento (se leen y se conservan); la fila solo se ve si la empresa usa redondeo por moneda (`OADM.RoundMthd`, `RoundingMethod = tYES` en el Service Layer; confirmado con el usuario; por documento no se muestra). Esa bandera y el separador de segmentos de las cuentas salen de `GET /api/company/settings` (backend: `CompanyService`, lee `CompanyService_GetAdminInfo` y entrega solo esos dos campos, con caché de 10 min). El importe solo cuenta con la casilla marcada; sin marcarla no se envía nada y SAP calcula el suyo. **Facturas y notas de crédito** (`billing: true`, `BILLING_DOCUMENT` en `screen-kit.ts`): el pie suma «Anticipo total» (`DownPaymentAmount`, tras el descuento), «Importe aplicado» (`PaidToDate`) y «Saldo pendiente» (`DocTotal − PaidToDate`, 0 en un documento nuevo), de solo lectura, como en SAP. **Gastos adicionales** hechos: tabla «Gastos adicionales» en el pie de ventas y compras (una fila por gasto: código con su ventana de selección sobre `AdditionalExpenses`, nombre, importe, impuesto y comentarios; colección `DocumentAdditionalExpenses`). Se suman al total con su propio impuesto (`computeTotals`); al modificar, si cambian las filas se envía la colección completa con `B1S-ReplaceCollectionsOnPatch` (quitarlas todas envía `[]`); `expenses: false` en `marketing(...)` la quita de un documento; las extensiones pueden ocultarla con el nombre `DocumentAdditionalExpenses`. Falta: botón PDF. En ventas un gasto adicional sin cuenta de ingresos falla en SBODemoCL ("Define the freight revenue account…"; configuración de SAP, no del código); en compras funciona (orden de compra 2885). 2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy. 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). 4. **Auditoría — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log. diff --git a/apps/web/src/app/features/documents/compras/compras.screens.ts b/apps/web/src/app/features/documents/compras/compras.screens.ts index 04b93d0..eb47cca 100644 --- a/apps/web/src/app/features/documents/compras/compras.screens.ts +++ b/apps/web/src/app/features/documents/compras/compras.screens.ts @@ -1,4 +1,4 @@ -import { TAX_DOCUMENT, type DocumentScreen, docDate, docNum, invoiceColumns, marketing, referenceColumns, statusColumn, statusFilter, dateFilter } from '../screen-kit'; +import { BILLING_DOCUMENT, TAX_DOCUMENT, type DocumentScreen, docDate, docNum, invoiceColumns, marketing, referenceColumns, statusColumn, statusFilter, dateFilter } from '../screen-kit'; import { purchaseRequestLayout } from './compras.layouts'; /** Compras: solicitudes de compra y documentos de proveedor. */ @@ -22,7 +22,7 @@ export const COMPRAS_SCREENS: readonly DocumentScreen[] = [ marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor', { columns: referenceColumns }), marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor', { columns: referenceColumns }), marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT), - marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }), + marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...BILLING_DOCUMENT, columns: invoiceColumns }), marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'), - marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT), + marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', BILLING_DOCUMENT), ]; diff --git a/apps/web/src/app/features/documents/document-expenses.spec.ts b/apps/web/src/app/features/documents/document-expenses.spec.ts index 6a5296c..d220eb4 100644 --- a/apps/web/src/app/features/documents/document-expenses.spec.ts +++ b/apps/web/src/app/features/documents/document-expenses.spec.ts @@ -139,6 +139,17 @@ describe('gastos adicionales en el formulario', () => { expect(el().querySelector('.status')!.textContent).toContain('indique el gasto'); }); + it('las facturas muestran anticipo, importe aplicado y saldo pendiente; una orden no', async () => { + await open('PurchaseInvoices', { ...order([]), DocTotal: 1000, PaidToDate: 400, DownPaymentAmount: 100 }); + const row = (label: string) => [...el().querySelectorAll('label')].find((l) => l.textContent?.trim() === label)?.closest('.sbo-row, sbo-text-field, div')?.querySelector('input')?.value; + expect(row('Importe aplicado')).toBe('400.00'); + expect(row('Saldo pendiente')).toBe('600.00'); + expect(row('Anticipo total')).toBe('100.00'); + TestBed.resetTestingModule(); + await open('Orders', order([])); + expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).not.toContain('Saldo pendiente'); + }); + it('las entradas de inventario no tienen gastos adicionales', () => { const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'InventoryGenEntries')!; expect(JSON.stringify(screen.layout.footer)).not.toContain('expenses'); 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 c21fdec..4ec3348 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -421,6 +421,14 @@ export class DocumentFormPage { return this.fmt(this.totals().expenses); case 'tax': return this.fmt(this.totals().tax); + case 'downPayment': + return this.fmt(Number(this.doc()?.['DownPaymentAmount'] ?? 0)); + case 'applied': + return this.fmt(Number(this.doc()?.['PaidToDate'] ?? 0)); + case 'balance': { + const d = this.doc(); + return this.fmt(d ? Number(d['DocTotal'] ?? 0) - Number(d['PaidToDate'] ?? 0) : 0); + } case 'total': return this.fmt(this.totals().total); } diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index e39c5e6..268afbc 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -7,7 +7,7 @@ import { SERIES_SUBTYPE } from './document-objects'; import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, newServiceLine, recalcLine, serviceLineColumns, uomEntryOf } from './line-columns'; /** Valores calculados o del socio que una fila de solo lectura puede mostrar. */ -export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'expenses' | 'tax' | 'total'; +export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'expenses' | 'tax' | 'total' | 'downPayment' | 'applied' | 'balance'; /** * Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista @@ -237,6 +237,8 @@ export function marketingLayout(opts: { uom: 'sales' | 'purchase'; /** Fila del flete en el pie (por defecto sí). */ expenses?: boolean; + /** Facturas y notas de crédito: el pie suma el anticipo total, el importe aplicado y el saldo pendiente. */ + billing?: boolean; /** Valores que se envían siempre al crear (ej. `DocumentSubType: 'bod_Bill'` en las boletas). */ fixed?: DocRecord; }): DocumentLayout { @@ -263,10 +265,15 @@ export function marketingLayout(opts: { right: [ { type: 'computed', label: 'Total antes del descuento', source: 'subtotal' }, { type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 }, + // facturas y notas de crédito: lo que ya se anticipó, se aplicó y falta por pagar (como en SAP) + ...(opts.billing ? [{ type: 'computed', label: 'Anticipo total', source: 'downPayment' } as const] : []), { type: 'computed', label: 'Gastos adicionales', source: 'expenses' }, { type: 'rounding', label: 'Redondeo' }, { type: 'computed', label: 'Impuesto', source: 'tax' }, { type: 'computed', label: 'Total del documento', source: 'total' }, + ...(opts.billing + ? [{ type: 'computed', label: 'Importe aplicado', source: 'applied' } as const, { type: 'computed', label: 'Saldo pendiente', source: 'balance' } as const] + : []), ], }, columns, diff --git a/apps/web/src/app/features/documents/screen-kit.ts b/apps/web/src/app/features/documents/screen-kit.ts index 495c0e6..2a576d3 100644 --- a/apps/web/src/app/features/documents/screen-kit.ts +++ b/apps/web/src/app/features/documents/screen-kit.ts @@ -142,6 +142,8 @@ export interface MarketingExtras { header?: FieldColumns; /** Filtro fijo de la lista y de la navegación entre documentos. */ baseFilter?: string; + /** Documento de cobro o pago (factura, nota de crédito): el pie muestra anticipo, importe aplicado y saldo pendiente. */ + billing?: boolean; /** Valores que se envían siempre al crear (ej. `DocumentSubType`). */ fixed?: DocRecord; /** Columnas de línea de este documento (de `LINE_COLUMNS`); sin ellas, todas. */ @@ -153,6 +155,9 @@ export interface MarketingExtras { /** Documentos tributarios: la cabecera suma el folio (en el Service Layer `FolioPrefixString` y `FolioNumber`). */ export const TAX_DOCUMENT: MarketingExtras = { headerRight: [FIELDS.folio] }; +/** Facturas y notas de crédito: documento tributario que además se cobra o se paga. */ +export const BILLING_DOCUMENT: MarketingExtras = { ...TAX_DOCUMENT, billing: true }; + /** Ventas y compras: documentos con socio de negocio y total. */ export const marketing = ( path: string, @@ -171,6 +176,7 @@ export const marketing = ( lineColumns: extras.lineColumns, lookups: extras.lookups, fixed: extras.fixed, + billing: extras.billing, uom: path.startsWith('/ventas') ? 'sales' : 'purchase', }), resource, diff --git a/apps/web/src/app/features/documents/ventas/ventas.screens.ts b/apps/web/src/app/features/documents/ventas/ventas.screens.ts index 9b7c88a..2a5cd0d 100644 --- a/apps/web/src/app/features/documents/ventas/ventas.screens.ts +++ b/apps/web/src/app/features/documents/ventas/ventas.screens.ts @@ -1,4 +1,4 @@ -import { TAX_DOCUMENT, type DocumentScreen, invoiceColumns, marketing, referenceColumns } from '../screen-kit'; +import { BILLING_DOCUMENT, TAX_DOCUMENT, type DocumentScreen, invoiceColumns, marketing, referenceColumns } from '../screen-kit'; /** * Filtros que separan las cuatro pantallas de `Invoices`: `ReserveInvoice` (reserva sí/no) y el subtipo @@ -21,29 +21,29 @@ export const VENTAS_SCREENS: readonly DocumentScreen[] = [ // Facturas, boletas y sus reservas son el mismo recurso (`Invoices`); cada pantalla filtra por su // `DocumentSubType` exacto y por `ReserveInvoice`, y envía ambos al crear. marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { - ...TAX_DOCUMENT, + ...BILLING_DOCUMENT, columns: invoiceColumns, baseFilter: `${NOT_RESERVE} and ${INVOICE}`, fixed: { DocumentSubType: 'bod_None' }, }), marketing('/ventas/boletas', 'Invoices', 'Nueva boleta', 'Cliente', { - ...TAX_DOCUMENT, + ...BILLING_DOCUMENT, columns: invoiceColumns, baseFilter: `${NOT_RESERVE} and ${BILL}`, fixed: { DocumentSubType: 'bod_Bill' }, }), marketing('/ventas/facturas-reserva', 'Invoices', 'Nueva factura de reserva', 'Cliente', { - ...TAX_DOCUMENT, + ...BILLING_DOCUMENT, columns: invoiceColumns, baseFilter: `${RESERVE} and ${INVOICE}`, fixed: { ReserveInvoice: 'tYES', DocumentSubType: 'bod_None' }, }), marketing('/ventas/boletas-reserva', 'Invoices', 'Nueva boleta de reserva', 'Cliente', { - ...TAX_DOCUMENT, + ...BILLING_DOCUMENT, columns: invoiceColumns, baseFilter: `${RESERVE} and ${BILL}`, fixed: { ReserveInvoice: 'tYES', DocumentSubType: 'bod_Bill' }, }), marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'), - marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT), + marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', BILLING_DOCUMENT), ];