feat(web): anticipo total, importe aplicado y saldo pendiente en el pie de facturas y notas de crédito
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c28207b0fa
commit
0b988733f6
7 files changed
+43
-11
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
Reference in new issue
Block a user