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:
1 parent
d926519f1b
commit
1834c64f6f
18 files changed
+122
-19
No files matched your search
@@ -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).
|
- **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`.
|
- **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.
|
- **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.
|
- 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
|
## Flujo de las pantallas de documento
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface SlResource {
|
|||||||
expand?: string[];
|
expand?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = (name: string, path: string): SlResource => ({
|
const document = (name: string, path: RequiredPath): SlResource => ({
|
||||||
name,
|
name,
|
||||||
key: 'number',
|
key: 'number',
|
||||||
methods: ['GET', 'POST', 'PATCH'],
|
methods: ['GET', 'POST', 'PATCH'],
|
||||||
@@ -44,7 +44,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
|
|||||||
document('Quotations', '/ventas/ofertas'),
|
document('Quotations', '/ventas/ofertas'),
|
||||||
document('Orders', '/ventas/ordenes'),
|
document('Orders', '/ventas/ordenes'),
|
||||||
document('DeliveryNotes', '/ventas/entrega'),
|
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('Returns', '/ventas/devoluciones'),
|
||||||
document('CreditNotes', '/ventas/notas-credito'),
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { SlListResponse } from '@basic-erp/sap-types';
|
import type { SlListResponse } from '@basic-erp/sap-types';
|
||||||
import { API_URL } from '../api';
|
import { API_URL } from '../api';
|
||||||
|
import { combineFilters } from './odata';
|
||||||
import { type DocRecord, type NavTarget, navigationQuery } from '../../features/documents/document-model';
|
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`. */
|
/** 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. */
|
/** 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);
|
const q = navigationQuery(target, from);
|
||||||
let params = new HttpParams().set('$select', 'DocEntry').set('$top', 1).set('$orderby', q.$orderby);
|
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 res = await firstValueFrom(this.http.get<SlListResponse<DocRecord>>(this.url(resource), { params }));
|
||||||
const entry = res.value[0]?.['DocEntry'];
|
const entry = res.value[0]?.['DocEntry'];
|
||||||
return typeof entry === 'number' ? entry : null;
|
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 () => {
|
it('al elegir un usuario marca sus pantallas (el módulo completo por /ventas/*)', async () => {
|
||||||
await click('.users li button', 0);
|
await click('.users li button', 0);
|
||||||
const checked = [...el().querySelectorAll<HTMLInputElement>('fieldset .items input')].filter((i) => i.checked);
|
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
|
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', () => {
|
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/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(copyTargets('/ventas/notas-credito')).toEqual([]);
|
||||||
expect(copySources('/ventas/ofertas')).toEqual([]);
|
expect(copySources('/ventas/ofertas')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const OBJECT_TYPES: Readonly<Record<string, number>> = {
|
|||||||
'/ventas/entrega': 15,
|
'/ventas/entrega': 15,
|
||||||
'/ventas/devoluciones': 16,
|
'/ventas/devoluciones': 16,
|
||||||
'/ventas/facturas': 13,
|
'/ventas/facturas': 13,
|
||||||
|
'/ventas/boletas': 13,
|
||||||
'/ventas/notas-credito': 14,
|
'/ventas/notas-credito': 14,
|
||||||
'/compras/ofertas': 540000006,
|
'/compras/ofertas': 540000006,
|
||||||
'/compras/ordenes': 22,
|
'/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). */
|
/** 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[]>> = {
|
export const COPY_FLOWS: Readonly<Record<string, readonly string[]>> = {
|
||||||
'/ventas/ofertas': ['/ventas/ordenes', '/ventas/entrega', '/ventas/facturas'],
|
'/ventas/ofertas': ['/ventas/ordenes', '/ventas/entrega', '/ventas/facturas', '/ventas/boletas'],
|
||||||
'/ventas/ordenes': ['/ventas/entrega', '/ventas/facturas'],
|
'/ventas/ordenes': ['/ventas/entrega', '/ventas/facturas', '/ventas/boletas'],
|
||||||
'/ventas/entrega': ['/ventas/facturas', '/ventas/devoluciones'],
|
'/ventas/entrega': ['/ventas/facturas', '/ventas/boletas', '/ventas/devoluciones'],
|
||||||
'/ventas/devoluciones': ['/ventas/notas-credito'],
|
'/ventas/devoluciones': ['/ventas/notas-credito'],
|
||||||
'/ventas/facturas': ['/ventas/notas-credito'],
|
'/ventas/facturas': ['/ventas/notas-credito'],
|
||||||
|
'/ventas/boletas': ['/ventas/notas-credito'],
|
||||||
'/compras/ofertas': ['/compras/ordenes'],
|
'/compras/ofertas': ['/compras/ordenes'],
|
||||||
'/compras/ordenes': ['/compras/recepcion', '/compras/facturas'],
|
'/compras/ordenes': ['/compras/recepcion', '/compras/facturas'],
|
||||||
'/compras/recepcion': ['/compras/facturas', '/compras/devoluciones'],
|
'/compras/recepcion': ['/compras/facturas', '/compras/devoluciones'],
|
||||||
|
|||||||
@@ -63,10 +63,13 @@ describe('folio y RUT en los formularios', () => {
|
|||||||
|
|
||||||
beforeEach(() => TestBed.resetTestingModule());
|
beforeEach(() => TestBed.resetTestingModule());
|
||||||
|
|
||||||
it('lo llevan facturas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
|
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.resource);
|
const withFolio = DOCUMENT_SCREENS.filter((s) => hasFolio(s.layout)).map((s) => s.path);
|
||||||
expect(withFolio.sort()).toEqual(
|
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', () => {
|
describe('copiar a', () => {
|
||||||
const items = () => [...el().querySelectorAll<HTMLButtonElement>('sbo-menu-button')].map((m) => m.textContent?.trim());
|
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());
|
await open('41', order());
|
||||||
expect(items().join(' ')).toContain('Copiar a');
|
expect(items().join(' ')).toContain('Copiar a');
|
||||||
const ids = page().copyToItems().map((i: { id: string; disabled: boolean }) => [i.id, i.disabled]);
|
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 () => {
|
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> {
|
protected async go(target: NavTarget): Promise<void> {
|
||||||
try {
|
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) {
|
if (entry === null) {
|
||||||
this.message.set({ kind: 'warn', text: 'No hay más documentos en esa dirección.' });
|
this.message.set({ kind: 'warn', text: 'No hay más documentos en esa dirección.' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('modelo de los formularios sin socio', () => {
|
|||||||
expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true);
|
expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true);
|
||||||
const withPartner = DOCUMENT_SCREENS.filter((s) => s.partner).map((s) => s.resource);
|
const withPartner = DOCUMENT_SCREENS.filter((s) => s.partner).map((s) => s.resource);
|
||||||
const without = 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(
|
expect(without.sort()).toEqual(
|
||||||
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
|
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export function marketingLayout(opts: {
|
|||||||
uom: 'sales' | 'purchase';
|
uom: 'sales' | 'purchase';
|
||||||
/** Fila del flete en el pie (por defecto sí). */
|
/** Fila del flete en el pie (por defecto sí). */
|
||||||
freight?: boolean;
|
freight?: boolean;
|
||||||
|
/** Valores que se envían siempre al crear (ej. `DocumentSubType: 'bod_Bill'` en las boletas). */
|
||||||
|
fixed?: DocRecord;
|
||||||
}): DocumentLayout {
|
}): DocumentLayout {
|
||||||
const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) =>
|
const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) =>
|
||||||
c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : 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 }),
|
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
|
||||||
newLine: newDocumentLine,
|
newLine: newDocumentLine,
|
||||||
recalc: recalcLine,
|
recalc: recalcLine,
|
||||||
|
...(opts.fixed ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } : {}),
|
||||||
requiredLine: [],
|
requiredLine: [],
|
||||||
};
|
};
|
||||||
return opts.lookups ? withLookupColumns(layout, opts.lookups, 'replace') : layout;
|
return opts.lookups ? withLookupColumns(layout, opts.lookups, 'replace') : layout;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { DocumentScreen } from './document-screens';
|
|||||||
[extraFields]="screen.extraFields ?? []"
|
[extraFields]="screen.extraFields ?? []"
|
||||||
[filters]="screen.filters"
|
[filters]="screen.filters"
|
||||||
[orderby]="screen.orderby"
|
[orderby]="screen.orderby"
|
||||||
|
[baseFilter]="screen.baseFilter"
|
||||||
rowKey="DocEntry"
|
rowKey="DocEntry"
|
||||||
[createLabel]="screen.createLabel"
|
[createLabel]="screen.createLabel"
|
||||||
(open)="open($event)"
|
(open)="open($event)"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const DOCUMENT_PATHS: readonly string[] = [
|
|||||||
'/ventas/ordenes',
|
'/ventas/ordenes',
|
||||||
'/ventas/entrega',
|
'/ventas/entrega',
|
||||||
'/ventas/facturas',
|
'/ventas/facturas',
|
||||||
|
'/ventas/boletas',
|
||||||
'/ventas/devoluciones',
|
'/ventas/devoluciones',
|
||||||
'/ventas/notas-credito',
|
'/ventas/notas-credito',
|
||||||
// Compras
|
// Compras
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ describe('DOCUMENT_SCREENS', () => {
|
|||||||
it('cada pantalla cuelga de una ruta del menú, sin repetir rutas ni recursos', () => {
|
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);
|
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.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', () => {
|
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');
|
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 { ListFilter } from '../../core/sl/list-filters';
|
||||||
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
|
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
|
||||||
import type { GridColumn } from '../../shared/grid/grid.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';
|
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). */
|
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
|
||||||
@@ -18,6 +19,8 @@ export interface DocumentScreen {
|
|||||||
filters: readonly ListFilter[];
|
filters: readonly ListFilter[];
|
||||||
/** Los más recientes primero. */
|
/** Los más recientes primero. */
|
||||||
orderby: string;
|
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). */
|
/** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */
|
||||||
partner?: {
|
partner?: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -121,6 +124,10 @@ export interface MarketingExtras {
|
|||||||
headerRight?: readonly HeaderField[];
|
headerRight?: readonly HeaderField[];
|
||||||
/** Cabecera completa (reemplaza a la base). */
|
/** Cabecera completa (reemplaza a la base). */
|
||||||
header?: FieldColumns;
|
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. */
|
/** Columnas de línea de este documento (de `LINE_COLUMNS`); sin ellas, todas. */
|
||||||
lineColumns?: readonly GridColumn[];
|
lineColumns?: readonly GridColumn[];
|
||||||
/** Columnas de las listas de valores de este documento (cliente, artículo, almacén, impuesto). */
|
/** 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,
|
header: extras.header,
|
||||||
lineColumns: extras.lineColumns,
|
lineColumns: extras.lineColumns,
|
||||||
lookups: extras.lookups,
|
lookups: extras.lookups,
|
||||||
|
fixed: extras.fixed,
|
||||||
uom: path.startsWith('/ventas') ? 'sales' : 'purchase',
|
uom: path.startsWith('/ventas') ? 'sales' : 'purchase',
|
||||||
}),
|
}),
|
||||||
resource,
|
resource,
|
||||||
createLabel,
|
createLabel,
|
||||||
orderby: 'DocEntry desc',
|
orderby: 'DocEntry desc',
|
||||||
|
baseFilter: extras.baseFilter,
|
||||||
partner: path.startsWith('/ventas')
|
partner: path.startsWith('/ventas')
|
||||||
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
|
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
|
||||||
: { label: partner, type: 'cSupplier', 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/ofertas', 'Quotations', 'Nueva oferta', 'Cliente', { columns: referenceColumns }),
|
||||||
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente', { columns: referenceColumns }),
|
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente', { columns: referenceColumns }),
|
||||||
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT),
|
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/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', TAX_DOCUMENT),
|
||||||
];
|
];
|
||||||
@@ -25,6 +25,7 @@ export const MENU: readonly MenuModule[] = [
|
|||||||
{ path: '/ventas/ordenes', label: 'Órdenes de venta' },
|
{ path: '/ventas/ordenes', label: 'Órdenes de venta' },
|
||||||
{ path: '/ventas/entrega', label: 'Entregas' },
|
{ path: '/ventas/entrega', label: 'Entregas' },
|
||||||
{ path: '/ventas/facturas', label: 'Facturas de venta' },
|
{ path: '/ventas/facturas', label: 'Facturas de venta' },
|
||||||
|
{ path: '/ventas/boletas', label: 'Boletas de venta' },
|
||||||
{ path: '/ventas/devoluciones', label: 'Devoluciones' },
|
{ path: '/ventas/devoluciones', label: 'Devoluciones' },
|
||||||
{ path: '/ventas/notas-credito', label: 'Notas de crédito' },
|
{ path: '/ventas/notas-credito', label: 'Notas de crédito' },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user