feat: boletas de venta (Invoices con DocumentSubType bod_Bill); la lista de facturas las excluye

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-15 17:13:33 -05:00
1 parent d926519f1b
commit 1834c64f6f
18 files changed
+122 -19

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
- **TLS:** `SAPB1_SL_CA_FILE` opcional (certificado autofirmado de SAP o CA de firewall); `VERIFY_SSL=true` por defecto y no se puede apagar en producción. `fortinet.cer` no sirve para SAP10: presenta su propio certificado autofirmado (`apps/api/certs/sap10.pem`, ignorado por git).
- **Proxy `/api/sl/:recurso`**: el frontend arma `$select/$filter/...`; el backend valida y ejecuta solo recursos registrados (`apps/api/src/sl/sl-resources.ts`), con permiso de menú y cabeceras `B1S-*` de lista blanca. Nunca registrar `EmployeesInfo` (tiene `U_WEB_PASS`). `InventoryRequests` no existe: es `InventoryTransferRequests`.
- **Permisos de menú** en SQLite (`SQLITE_PATH`), patrones `*`, `/ventas/*`, `/ventas/ordenes`; `ADMIN_WEB_USERS` garantiza el acceso total; no se puede quitar el último admin.
- **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base `DocumentSubType`** (excluiría notas de débito, etc.). El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión).
- **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base `DocumentSubType`** salvo las boletas: la boleta de venta es una `Invoices` con `DocumentSubType = bod_Bill` (pantalla `/ventas/boletas`, `baseFilter` + `fixed` al crear); la lista de facturas solo *excluye* `bod_Bill` (`ne`, no `eq bod_None`, para no perder notas de débito u otros subtipos). En SBODemoCL hay `bod_None` y `bod_Bill`. `baseFilter` de la pantalla se aplica también a la navegación primero/anterior/siguiente/último. El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión).
- Reportes/PDF **no** se generan en la base; el botón PDF queda pendiente hasta migrar el API Gateway.
## Flujo de las pantallas de documento
+3 -2
View File
@@ -17,7 +17,7 @@ export interface SlResource {
expand?: string[];
}
const document = (name: string, path: string): SlResource => ({
const document = (name: string, path: RequiredPath): SlResource => ({
name,
key: 'number',
methods: ['GET', 'POST', 'PATCH'],
@@ -44,7 +44,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
document('Quotations', '/ventas/ofertas'),
document('Orders', '/ventas/ordenes'),
document('DeliveryNotes', '/ventas/entrega'),
document('Invoices', '/ventas/facturas'),
// Las boletas de venta son facturas con `DocumentSubType = bod_Bill`: mismo recurso, dos pantallas.
document('Invoices', ['/ventas/facturas', '/ventas/boletas']),
document('Returns', '/ventas/devoluciones'),
document('CreditNotes', '/ventas/notas-credito'),
@@ -0,0 +1,41 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { SlDocumentApi } from './sl-document';
describe('SlDocumentApi.neighbour', () => {
let api: SlDocumentApi;
let ctrl: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
api = TestBed.inject(SlDocumentApi);
ctrl = TestBed.inject(HttpTestingController);
});
it('pide el documento anterior por DocEntry', async () => {
const p = api.neighbour('Orders', 'prev', 41);
const req = ctrl.expectOne((r) => r.url === '/api/sl/Orders');
expect(req.request.params.get('$filter')).toBe('DocEntry lt 41');
expect(req.request.params.get('$orderby')).toBe('DocEntry desc');
req.flush({ value: [{ DocEntry: 40 }] });
expect(await p).toBe(40);
});
it('la navegación respeta el filtro fijo de la pantalla (boletas dentro de las facturas)', async () => {
const p = api.neighbour('Invoices', 'next', 7, `DocumentSubType eq 'bod_Bill'`);
const req = ctrl.expectOne((r) => r.url === '/api/sl/Invoices');
expect(req.request.params.get('$filter')).toBe(`(DocumentSubType eq 'bod_Bill') and (DocEntry gt 7)`);
req.flush({ value: [] });
expect(await p).toBeNull();
});
it('con filtro fijo y sin documento actual solo aplica el fijo', async () => {
const p = api.neighbour('Invoices', 'first', null, `DocumentSubType ne 'bod_Bill'`);
const req = ctrl.expectOne((r) => r.url === '/api/sl/Invoices');
expect(req.request.params.get('$filter')).toBe(`DocumentSubType ne 'bod_Bill'`);
req.flush({ value: [{ DocEntry: 1 }] });
expect(await p).toBe(1);
});
});
+4 -2
View File
@@ -3,6 +3,7 @@ import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import type { SlListResponse } from '@basic-erp/sap-types';
import { API_URL } from '../api';
import { combineFilters } from './odata';
import { type DocRecord, type NavTarget, navigationQuery } from '../../features/documents/document-model';
/** Lectura y escritura de un documento del Service Layer a través del proxy `/api/sl`. */
@@ -20,10 +21,11 @@ export class SlDocumentApi {
}
/** DocEntry del documento vecino (primero / anterior / siguiente / último), o `null` si no hay. */
async neighbour(resource: string, target: NavTarget, from: number | null): Promise<number | null> {
async neighbour(resource: string, target: NavTarget, from: number | null, baseFilter?: string): Promise<number | null> {
const q = navigationQuery(target, from);
let params = new HttpParams().set('$select', 'DocEntry').set('$top', 1).set('$orderby', q.$orderby);
if (q.$filter) params = params.set('$filter', q.$filter);
const filter = combineFilters(baseFilter, q.$filter);
if (filter) params = params.set('$filter', filter);
const res = await firstValueFrom(this.http.get<SlListResponse<DocRecord>>(this.url(resource), { params }));
const entry = res.value[0]?.['DocEntry'];
return typeof entry === 'number' ? entry : null;
@@ -44,7 +44,7 @@ describe('PermissionsPage', () => {
it('al elegir un usuario marca sus pantallas (el módulo completo por /ventas/*)', async () => {
await click('.users li button', 0);
const checked = [...el().querySelectorAll<HTMLInputElement>('fieldset .items input')].filter((i) => i.checked);
expect(checked.length).toBe(7); // 6 de ventas + Clientes
expect(checked.length).toBe(8); // 7 de ventas + Clientes
expect(el().querySelector<HTMLButtonElement>('.actions .main')!.disabled).toBe(true); // sin cambios
});
@@ -20,8 +20,10 @@ describe('flujos de copiado', () => {
});
it('copiar a / copiar de son los dos lados del mismo flujo', () => {
expect(copyTargets('/ventas/ordenes')).toEqual(['/ventas/entrega', '/ventas/facturas']);
expect(copyTargets('/ventas/ordenes')).toEqual(['/ventas/entrega', '/ventas/facturas', '/ventas/boletas']);
expect(copySources('/ventas/facturas')).toEqual(['/ventas/ofertas', '/ventas/ordenes', '/ventas/entrega']);
expect(copySources('/ventas/boletas')).toEqual(['/ventas/ofertas', '/ventas/ordenes', '/ventas/entrega']);
expect(copyTargets('/ventas/boletas')).toEqual(['/ventas/notas-credito']);
expect(copyTargets('/ventas/notas-credito')).toEqual([]);
expect(copySources('/ventas/ofertas')).toEqual([]);
});
@@ -9,6 +9,7 @@ export const OBJECT_TYPES: Readonly<Record<string, number>> = {
'/ventas/entrega': 15,
'/ventas/devoluciones': 16,
'/ventas/facturas': 13,
'/ventas/boletas': 13,
'/ventas/notas-credito': 14,
'/compras/ofertas': 540000006,
'/compras/ordenes': 22,
@@ -20,11 +21,12 @@ export const OBJECT_TYPES: Readonly<Record<string, number>> = {
/** De qué documento se puede copiar a cuáles (el flujo estándar de SAP B1). */
export const COPY_FLOWS: Readonly<Record<string, readonly string[]>> = {
'/ventas/ofertas': ['/ventas/ordenes', '/ventas/entrega', '/ventas/facturas'],
'/ventas/ordenes': ['/ventas/entrega', '/ventas/facturas'],
'/ventas/entrega': ['/ventas/facturas', '/ventas/devoluciones'],
'/ventas/ofertas': ['/ventas/ordenes', '/ventas/entrega', '/ventas/facturas', '/ventas/boletas'],
'/ventas/ordenes': ['/ventas/entrega', '/ventas/facturas', '/ventas/boletas'],
'/ventas/entrega': ['/ventas/facturas', '/ventas/boletas', '/ventas/devoluciones'],
'/ventas/devoluciones': ['/ventas/notas-credito'],
'/ventas/facturas': ['/ventas/notas-credito'],
'/ventas/boletas': ['/ventas/notas-credito'],
'/compras/ofertas': ['/compras/ordenes'],
'/compras/ordenes': ['/compras/recepcion', '/compras/facturas'],
'/compras/recepcion': ['/compras/facturas', '/compras/devoluciones'],
@@ -63,10 +63,13 @@ describe('folio y RUT en los formularios', () => {
beforeEach(() => TestBed.resetTestingModule());
it('lo llevan facturas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
const withFolio = DOCUMENT_SCREENS.filter((s) => hasFolio(s.layout)).map((s) => s.resource);
it('lo llevan facturas, boletas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
const withFolio = DOCUMENT_SCREENS.filter((s) => hasFolio(s.layout)).map((s) => s.path);
expect(withFolio.sort()).toEqual(
['CreditNotes', 'DeliveryNotes', 'Invoices', 'PurchaseCreditNotes', 'PurchaseDeliveryNotes', 'PurchaseInvoices'].sort(),
[
'/ventas/facturas', '/ventas/boletas', '/ventas/notas-credito', '/ventas/entrega',
'/compras/facturas', '/compras/notas-credito', '/compras/recepcion',
].sort(),
);
});
@@ -225,11 +225,11 @@ describe('DocumentFormPage', () => {
describe('copiar a', () => {
const items = () => [...el().querySelectorAll<HTMLButtonElement>('sbo-menu-button')].map((m) => m.textContent?.trim());
it('un documento abierto y guardado ofrece Copiar a con entrega y factura', async () => {
it('un documento abierto y guardado ofrece Copiar a con entrega, factura y boleta', async () => {
await open('41', order());
expect(items().join(' ')).toContain('Copiar a');
const ids = page().copyToItems().map((i: { id: string; disabled: boolean }) => [i.id, i.disabled]);
expect(ids).toEqual([['/ventas/entrega', false], ['/ventas/facturas', false]]);
expect(ids).toEqual([['/ventas/entrega', false], ['/ventas/facturas', false], ['/ventas/boletas', false]]);
});
it('abre el documento nuevo de destino llevando el origen', async () => {
@@ -352,7 +352,7 @@ export class DocumentFormPage {
protected async go(target: NavTarget): Promise<void> {
try {
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry());
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry(), this.screen.baseFilter);
if (entry === null) {
this.message.set({ kind: 'warn', text: 'No hay más documentos en esa dirección.' });
return;
@@ -19,7 +19,7 @@ describe('modelo de los formularios sin socio', () => {
expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true);
const withPartner = DOCUMENT_SCREENS.filter((s) => s.partner).map((s) => s.resource);
const without = DOCUMENT_SCREENS.filter((s) => !s.partner).map((s) => s.resource);
expect(withPartner).toHaveLength(12);
expect(withPartner).toHaveLength(13);
expect(without.sort()).toEqual(
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
);
@@ -174,6 +174,8 @@ export function marketingLayout(opts: {
uom: 'sales' | 'purchase';
/** Fila del flete en el pie (por defecto sí). */
freight?: boolean;
/** Valores que se envían siempre al crear (ej. `DocumentSubType: 'bod_Bill'` en las boletas). */
fixed?: DocRecord;
}): DocumentLayout {
const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) =>
c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : c,
@@ -204,6 +206,7 @@ export function marketingLayout(opts: {
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
newLine: newDocumentLine,
recalc: recalcLine,
...(opts.fixed ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } : {}),
requiredLine: [],
};
return opts.lookups ? withLookupColumns(layout, opts.lookups, 'replace') : layout;
@@ -17,6 +17,7 @@ import type { DocumentScreen } from './document-screens';
[extraFields]="screen.extraFields ?? []"
[filters]="screen.filters"
[orderby]="screen.orderby"
[baseFilter]="screen.baseFilter"
rowKey="DocEntry"
[createLabel]="screen.createLabel"
(open)="open($event)"
@@ -9,6 +9,7 @@ export const DOCUMENT_PATHS: readonly string[] = [
'/ventas/ordenes',
'/ventas/entrega',
'/ventas/facturas',
'/ventas/boletas',
'/ventas/devoluciones',
'/ventas/notas-credito',
// Compras
@@ -10,7 +10,9 @@ describe('DOCUMENT_SCREENS', () => {
it('cada pantalla cuelga de una ruta del menú, sin repetir rutas ni recursos', () => {
for (const s of DOCUMENT_SCREENS) expect(menuPaths.has(s.path), s.path).toBe(true);
expect(new Set(DOCUMENT_SCREENS.map((s) => s.path)).size).toBe(DOCUMENT_SCREENS.length);
expect(new Set(DOCUMENT_SCREENS.map((s) => s.resource)).size).toBe(DOCUMENT_SCREENS.length);
// facturas y boletas comparten el recurso `Invoices` (las separa el filtro `DocumentSubType`); los demás no se repiten
const byResource = DOCUMENT_SCREENS.filter((s) => s.resource !== 'Invoices');
expect(new Set(byResource.map((s) => s.resource)).size).toBe(byResource.length);
});
it('todas ordenan por los más recientes y tienen una columna buscable por defecto', () => {
@@ -118,3 +120,26 @@ describe('referencia y RUT en las listas de ofertas y órdenes', () => {
expect(keys('DeliveryNotes')).not.toContain('NumAtCard');
});
});
describe('facturas y boletas de venta', () => {
const by = (p: string) => DOCUMENT_SCREENS.find((s) => s.path === p)!;
it('comparten el recurso Invoices y se separan por el subtipo de documento', () => {
expect(by('/ventas/facturas').resource).toBe('Invoices');
expect(by('/ventas/boletas').resource).toBe('Invoices');
expect(by('/ventas/boletas').baseFilter).toBe(`DocumentSubType eq 'bod_Bill'`);
// la factura solo excluye las boletas: no pierde notas de débito ni otros subtipos
expect(by('/ventas/facturas').baseFilter).toBe(`DocumentSubType ne 'bod_Bill'`);
});
it('una boleta nueva se crea con su subtipo; una factura no lo envía', () => {
expect(by('/ventas/boletas').layout.beforeCreate!({ CardCode: 'C1' })).toEqual({ CardCode: 'C1', DocumentSubType: 'bod_Bill' });
expect(by('/ventas/facturas').layout.beforeCreate).toBeUndefined();
});
it('el resto de las pantallas no tiene filtro fijo', () => {
for (const s of DOCUMENT_SCREENS.filter((s) => !['/ventas/facturas', '/ventas/boletas'].includes(s.path))) {
expect(s.baseFilter, s.path).toBeUndefined();
}
});
});
@@ -3,6 +3,7 @@ import { formatNumber } from '../../core/format';
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
import type { GridColumn } from '../../shared/grid/grid.types';
import type { DocRecord } from './document-model';
import { type DocumentLayout, FIELDS, type FieldColumns, type HeaderField, type LookupColumns, marketingLayout } from './document-layouts';
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
@@ -18,6 +19,8 @@ export interface DocumentScreen {
filters: readonly ListFilter[];
/** Los más recientes primero. */
orderby: string;
/** Filtro fijo de la lista y de la navegación (ej. las boletas son las `Invoices` con `DocumentSubType = bod_Bill`). */
baseFilter?: string;
/** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */
partner?: {
label: string;
@@ -121,6 +124,10 @@ export interface MarketingExtras {
headerRight?: readonly HeaderField[];
/** Cabecera completa (reemplaza a la base). */
header?: FieldColumns;
/** Filtro fijo de la lista y de la navegación entre documentos. */
baseFilter?: string;
/** 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. */
lineColumns?: readonly GridColumn[];
/** Columnas de las listas de valores de este documento (cliente, artículo, almacén, impuesto). */
@@ -147,11 +154,13 @@ export const marketing = (
header: extras.header,
lineColumns: extras.lineColumns,
lookups: extras.lookups,
fixed: extras.fixed,
uom: path.startsWith('/ventas') ? 'sales' : 'purchase',
}),
resource,
createLabel,
orderby: 'DocEntry desc',
baseFilter: extras.baseFilter,
partner: path.startsWith('/ventas')
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
: { label: partner, type: 'cSupplier', taxResource: 'SalesTaxCodes' },
@@ -7,7 +7,19 @@ export const VENTAS_SCREENS: readonly DocumentScreen[] = [
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente', { columns: referenceColumns }),
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente', { columns: referenceColumns }),
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT),
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }),
// Facturas y boletas son el mismo recurso (`Invoices`): la boleta es la de subtipo `bod_Bill`. La factura
// excluye solo las boletas (no filtra por `bod_None`, para no perder notas de débito u otros subtipos).
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', {
...TAX_DOCUMENT,
columns: invoiceColumns,
baseFilter: `DocumentSubType ne 'bod_Bill'`,
}),
marketing('/ventas/boletas', 'Invoices', 'Nueva boleta', 'Cliente', {
...TAX_DOCUMENT,
columns: invoiceColumns,
baseFilter: `DocumentSubType eq 'bod_Bill'`,
fixed: { DocumentSubType: 'bod_Bill' },
}),
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT),
];
+1
View File
@@ -25,6 +25,7 @@ export const MENU: readonly MenuModule[] = [
{ path: '/ventas/ordenes', label: 'Órdenes de venta' },
{ path: '/ventas/entrega', label: 'Entregas' },
{ path: '/ventas/facturas', label: 'Facturas de venta' },
{ path: '/ventas/boletas', label: 'Boletas de venta' },
{ path: '/ventas/devoluciones', label: 'Devoluciones' },
{ path: '/ventas/notas-credito', label: 'Notas de crédito' },
],