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).
|
||||
- **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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user