feat(web): fila del flete (gastos adicionales) y toolbar de ancho completo; la configuración de pantallas se carga en diferido

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 16:09:06 -05:00
1 parent d3ab97cd2e
commit d6c127627f
14 files changed
+360 -27

No files matched your search

+2 -2
View File
@@ -19,7 +19,7 @@ Monorepo npm workspaces:
## Criterios de la interfaz
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~287 kB, 81 kB comprimido: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
## Comandos
@@ -60,7 +60,7 @@ Pendiente, en orden:
**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número).
**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo.
**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (`<sbo-toolbar bleed>`). Falta: botón PDF.
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
+2
View File
@@ -74,6 +74,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('SalesPersons', 'number'),
catalog('PriceLists', 'number'),
catalog('UnitOfMeasurements', 'number'),
// Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto.
catalog('AdditionalExpenses', 'number'),
];
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
+14 -1
View File
@@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
import { describe, expect, it } from 'vitest';
import { routes } from './app.routes';
import { permissionGuard } from './core/auth/guards';
import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { DOCUMENT_SCREENS } from './features/documents/document-screens';
const shell = routes.find((r) => r.path === '')!;
@@ -21,7 +22,19 @@ describe('rutas', () => {
for (const screen of DOCUMENT_SCREENS) {
const route = children.find((r) => r.path === screen.path.slice(1))!;
expect(route.children?.map((c) => c.path), screen.path).toEqual(['', 'nuevo', ':docEntry']);
expect(route.data?.['screen']).toBe(screen);
expect(route.resolve?.['screen'], screen.path).toBeDefined(); // la configuración se carga al abrir la pantalla
}
});
it('la lista de rutas de documento del enrutador coincide con las pantallas', () => {
expect([...DOCUMENT_PATHS].sort()).toEqual(DOCUMENT_SCREENS.map((s) => s.path).sort());
});
it('el resolver entrega la pantalla de su ruta', async () => {
for (const path of ['/ventas/ordenes', '/inventario/entradas']) {
const route = children.find((r) => r.path === path.slice(1))!;
const screen = await (route.resolve!['screen'] as () => Promise<{ path: string }>)();
expect(screen.path).toBe(path);
}
});
+12 -6
View File
@@ -2,18 +2,24 @@ import { isDevMode } from '@angular/core';
import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
import { DOCUMENT_SCREENS, menuLabel } from './features/documents/document-screens';
import { DOCUMENT_PATHS } from './features/documents/document-paths';
const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
/**
* Pantallas de documento: la ruta del menú muestra la lista de los últimos documentos;
* `nuevo` abre el formulario vacío y `:docEntry` el documento para ver o editar.
* El permiso de menú gobierna las tres (el guard del padre; `data` se hereda en los hijos).
* La configuración de la pantalla (campos, columnas) se carga en diferido: no va en el bundle inicial.
*/
const documentRoutes: Routes = DOCUMENT_SCREENS.map(
(screen): Route => ({
path: screen.path.slice(1),
const documentRoutes: Routes = DOCUMENT_PATHS.map(
(path): Route => ({
path: path.slice(1),
canActivate: [permissionGuard],
data: { path: screen.path, title: menuLabel(screen.path), screen },
data: { path, title: menuLabel(path) },
resolve: {
screen: () => import('./features/documents/document-screens').then((m) => m.DOCUMENT_SCREENS.find((s) => s.path === path)!),
},
children: [
{
path: '',
@@ -54,7 +60,7 @@ const adminRoutes: Routes = [
];
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const builtPaths = new Set([...DOCUMENT_SCREENS.map((s) => s.path), '/admin/permisos']);
const builtPaths = new Set([...DOCUMENT_PATHS, '/admin/permisos']);
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !builtPaths.has(item.path))
.map(
@@ -1,4 +1,4 @@
<sbo-toolbar aria-label="Acciones del documento">
<sbo-toolbar bleed aria-label="Acciones del documento">
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Documento nuevo"><sbo-icon name="new" />Nuevo</a>
<span class="tb-sep"></span>
@@ -156,6 +156,26 @@
/>
</sbo-field>
}
@case ('freight') {
<sbo-field [label]="f.label">
<sbo-select
width="150px"
ariaLabel="Gasto adicional del flete"
placeholder=""
[options]="expenseOptions()"
[readonly]="readOnly() || !freightEditable()"
[value]="str(header()['FreightExpense']) || defaultExpense() || null"
(valueChange)="setHeader('FreightExpense', $event ?? '')"
/>
<sbo-number
ariaLabel="Importe del flete"
[decimals]="2"
[readonly]="readOnly() || !freightEditable()"
[value]="+str(header()['FreightAmount'])"
(valueChange)="setHeader('FreightAmount', $event ?? 0)"
/>
</sbo-field>
}
@case ('computed') {
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
}
@@ -76,7 +76,7 @@ describe('DocumentFormPage', () => {
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto');
expect(el().querySelector('h1')?.textContent).toContain('1041');
expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1);
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, total: 238 });
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, freight: 0, total: 238 });
expect(buttonText()).toContain('Actualizar');
});
@@ -8,7 +8,7 @@ import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, NumberField, SboField, SelectField, TextField, TextInput, TextareaField } from '../../shared/form';
import { DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
@@ -19,6 +19,8 @@ import {
type DocRecord,
type NavTarget,
computeTotals,
freightOf,
freightPayload,
createPayload,
isBlankLine,
isDocumentReadOnly,
@@ -26,7 +28,7 @@ import {
updatePayload,
validateLayout,
} from './document-model';
import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, headerKeysOf, requiredHeaderOf } from './document-layouts';
import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, headerKeysOf, requiredHeaderOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { ITEMS_MULTI_ADD } from './line-columns';
@@ -60,7 +62,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SelectField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -83,8 +85,12 @@ export class DocumentFormPage {
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
private readonly hasFreight = hasFreight(this.layout);
private pick(doc: DocRecord | undefined): DocRecord {
return pickHeader(doc, this.headerKeys, this.blankHeader);
const header = pickHeader(doc, this.headerKeys, this.blankHeader);
if (!doc || !this.hasFreight) return header;
const freight = freightOf(doc);
return { ...header, FreightExpense: freight.expense, FreightAmount: freight.amount };
}
private docLines(doc: DocRecord | undefined): GridLine[] {
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
@@ -107,6 +113,23 @@ export class DocumentFormPage {
: undefined,
);
/** Gastos adicionales de SAP (el flete se registra con uno de ellos). */
private readonly expensesResource = httpResource<SlListResponse<DocRecord>>(() =>
this.hasFreight ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 100 } } : undefined,
);
protected readonly expenseOptions = computed(() =>
(this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => ({
value: String(e['ExpensCode']),
label: text(e['Name']) || String(e['ExpensCode']),
})),
);
/** Gasto que se usa si el usuario no elige: el que se llame "flete" o, si no hay, el primero. */
protected readonly defaultExpense = computed(() => {
const options = this.expenseOptions();
return (options.find((o) => /flete|freight/i.test(o.label)) ?? options[0])?.value ?? '';
});
protected readonly freightEditable = computed(() => freightOf(this.doc()).editable);
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
protected readonly loading = computed(() => this.docResource.isLoading());
protected readonly loadError = computed(() => {
@@ -170,7 +193,7 @@ export class DocumentFormPage {
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
);
protected readonly totals = computed(() =>
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0)),
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), Number(this.header()['FreightAmount'] ?? 0)),
);
protected readonly fmt = (n: number): string => formatNumber(n, 2);
@@ -355,7 +378,10 @@ export class DocumentFormPage {
const d = this.doc();
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
const body = this.hasFreight
? freightPayload(createPayload(header, this.lines(), this.lineSpec), this.header(), this.defaultExpense(), 'create').body
: createPayload(header, this.lines(), this.lineSpec);
const created = await this.api.create(this.screen.resource, body);
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
state: { created: text(created['DocNum']) },
@@ -363,8 +389,13 @@ export class DocumentFormPage {
return;
}
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
const body = folioAsNumber(update.body);
const replaceLines = update.replaceLines;
let body = folioAsNumber(update.body);
let replaceLines = update.replaceLines;
if (this.hasFreight) {
const freight = freightPayload(body, this.header(), this.defaultExpense(), 'update', freightOf(d).amount > 0);
body = freight.body;
replaceLines = replaceLines || freight.replace;
}
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;
@@ -0,0 +1,130 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
const EXPENSES = { value: [{ ExpensCode: 1, Name: 'Seguro' }, { ExpensCode: 2, Name: 'Flete' }] };
describe('flete en el formulario', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(resource: string, doc: Record<string, unknown> | null) {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!;
const params = doc ? { docEntry: '9' } : {};
const route = {
snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap(params) },
paramMap: of(convertToParamMap(params)),
};
await TestBed.configureTestingModule({
imports: [DocumentFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(DocumentFormPage);
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [{ Code: 'IVA', Rate: 19 }] });
ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush(EXPENSES);
if (doc) ctrl.expectOne(`/api/sl/${resource}/9`).flush(doc);
await tick();
}
const order = (rows: Record<string, unknown>[]) => ({
'odata.etag': 'W/"1"',
DocEntry: 9,
DocNum: 9,
CardCode: 'C1',
CardName: 'ACME',
DocDate: '2026-09-11T00:00:00Z',
DocDueDate: '2026-09-20T00:00:00Z',
DiscountPercent: 0,
DocumentStatus: 'bost_Open',
Cancelled: 'tNO',
DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 100, DiscountPercent: 0, TaxCode: 'IVA', LineTotal: 100, WarehouseCode: '01' }],
DocumentAdditionalExpenses: rows,
});
beforeEach(() => TestBed.resetTestingModule());
it('muestra la fila Flete con el gasto y el importe que trae SAP, y lo suma al total', async () => {
await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }]));
expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toContain('Flete');
expect(el().querySelector<HTMLSelectElement>('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2');
expect(page().totals()).toMatchObject({ freight: 25, total: 144 });
});
it('un documento sin flete usa por defecto el gasto llamado flete', async () => {
await open('Orders', order([]));
expect(page().defaultExpense()).toBe('2');
expect(el().querySelector<HTMLSelectElement>('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2');
});
it('crea el documento con DocumentAdditionalExpenses', async () => {
await open('Orders', null);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]);
page().setHeader('FreightAmount', 1500);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
const body = req.request.body as Record<string, unknown>;
expect(body['DocumentAdditionalExpenses']).toEqual([{ ExpenseCode: 2, LineTotal: 1500 }]);
expect(body).not.toHaveProperty('FreightAmount');
expect(body).not.toHaveProperty('FreightExpense');
req.flush({ DocEntry: 10, DocNum: 10 });
await tick();
expect(navigate).toHaveBeenCalled();
});
it('un documento sin flete no envía la colección', async () => {
await open('Orders', null);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record<string, unknown>;
expect(body).not.toHaveProperty('DocumentAdditionalExpenses');
});
it('al cambiar el importe envía la colección reemplazándola', async () => {
await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }]));
page().setHeader('FreightAmount', 40);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.body).toEqual({ DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 40 }] });
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
req.flush(null);
await tick();
ctrl.expectOne('/api/sl/Orders/9').flush(order([{ ExpenseCode: 2, LineTotal: 40 }]));
});
it('con varios gastos adicionales en SAP el flete solo se muestra, no se edita', async () => {
await open('Orders', order([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }]));
expect(page().freightEditable()).toBe(false);
expect(page().totals().freight).toBe(15);
expect(page().dirty()).toBe(false);
});
it('las entradas de inventario no tienen flete ni piden gastos adicionales', async () => {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'InventoryGenEntries')!;
expect(JSON.stringify(screen.layout.footer)).not.toContain('freight');
});
});
@@ -226,7 +226,7 @@ describe('columnas de línea por documento', () => {
it('un documento puede traer su cabecera completa en lugar de la base', () => {
const l = marketingLayout({ ...base, header: { left: [], right: [] } });
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent']);
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'FreightExpense', 'FreightAmount']);
});
it('los demás documentos no cambian cuando uno elige columnas', () => {
@@ -24,6 +24,8 @@ export type HeaderField =
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
| { type: 'freight'; label: string }
| { type: 'computed'; label: string; source: ComputedSource };
/** Las listas de valores (ventanas modales) de un formulario. */
@@ -170,6 +172,8 @@ export function marketingLayout(opts: {
lookups?: LookupColumns;
/** Unidad de medida que se toma del artículo: la de venta o la de compra. */
uom: 'sales' | 'purchase';
/** Fila del flete en el pie (por defecto sí). */
freight?: boolean;
}): DocumentLayout {
const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) =>
c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : c,
@@ -191,6 +195,7 @@ export function marketingLayout(opts: {
right: [
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
...(opts.freight === false ? [] : [{ type: 'freight', label: 'Flete' } as const]),
{ type: 'computed', label: 'Impuesto', source: 'tax' },
{ type: 'computed', label: 'Total del documento', source: 'total' },
],
@@ -219,6 +224,8 @@ export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
switch (f.type) {
case 'computed':
return [];
case 'freight':
return ['FreightExpense', 'FreightAmount'];
case 'folio':
return [f.prefixKey, f.numberKey];
default:
@@ -234,4 +241,6 @@ export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label:
: [],
);
export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight');
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
@@ -9,6 +9,8 @@ import {
taxRateMap,
updatePayload,
validateLayout,
freightOf,
freightPayload,
} from './document-model';
const line = (over: Record<string, unknown> = {}) => ({
@@ -35,12 +37,12 @@ describe('computeTotals', () => {
it('suma líneas e impuesto por tasa', () => {
const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0);
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, total: 338 });
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, freight: 0, total: 338 });
});
it('aplica el descuento de cabecera antes del impuesto', () => {
const t = computeTotals([line()], rates, 10);
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, total: 214.2 });
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, freight: 0, total: 214.2 });
});
it('un código de impuesto desconocido no suma impuesto', () => {
@@ -48,7 +50,7 @@ describe('computeTotals', () => {
});
it('sin líneas, todo en cero', () => {
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, total: 0 });
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, freight: 0, total: 0 });
});
});
@@ -151,3 +153,40 @@ describe('navigationQuery', () => {
expect(navigationQuery('next', null)).toEqual({ $orderby: 'DocEntry asc' });
});
});
describe('flete (gastos adicionales)', () => {
const doc = (rows: Record<string, unknown>[]) => ({ DocumentAdditionalExpenses: rows });
it('suma el flete al total del documento', () => {
const rates = taxRateMap([{ Code: 'IVA', Rate: 19 }]);
const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, 25);
expect(t).toMatchObject({ subtotal: 100, tax: 19, freight: 25, total: 144 });
});
it('lee el flete de SAP: código, importe y si se puede editar', () => {
expect(freightOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }]))).toEqual({ expense: '2', amount: 1500, editable: true });
expect(freightOf(undefined)).toEqual({ expense: '', amount: 0, editable: true });
expect(freightOf(doc([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }])).editable).toBe(false);
});
it('al crear arma DocumentAdditionalExpenses con el gasto por defecto si no se eligió', () => {
const out = freightPayload({ CardCode: 'C1', FreightAmount: 1500, FreightExpense: '' }, { FreightAmount: 1500, FreightExpense: '' }, '2', 'create');
expect(out.body).toEqual({ CardCode: 'C1', DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 1500 }] });
expect(out.replace).toBe(true);
});
it('al crear sin importe no envía flete', () => {
const out = freightPayload({ CardCode: 'C1', FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'create');
expect(out.body).toEqual({ CardCode: 'C1' });
expect(out.replace).toBe(false);
});
it('al modificar solo envía el flete si cambió, y con importe 0 lo quita', () => {
expect(freightPayload({ Comments: 'x' }, { FreightAmount: 10 }, '2', 'update', true)).toEqual({ body: { Comments: 'x' }, replace: false });
const changed = freightPayload({ FreightAmount: 300 }, { FreightAmount: 300, FreightExpense: '3' }, '2', 'update', true);
expect(changed).toEqual({ body: { DocumentAdditionalExpenses: [{ ExpenseCode: 3, LineTotal: 300 }] }, replace: true });
const cleared = freightPayload({ FreightAmount: 0 }, { FreightAmount: 0, FreightExpense: '3' }, '2', 'update', true);
expect(cleared).toEqual({ body: { DocumentAdditionalExpenses: [] }, replace: true });
expect(freightPayload({ FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'update', false).body).toEqual({});
});
});
@@ -20,7 +20,12 @@ export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean {
// --- totales ------------------------------------------------------------------------------
/** Gasto adicional del documento (el flete): colección `DocumentAdditionalExpenses` de SAP. */
export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses';
export interface DocTotals {
/** Flete (gastos adicionales) antes de impuestos. */
freight: number;
/** Suma de los totales de línea (ya con el descuento de cada línea). */
subtotal: number;
/** Descuento del documento (% de cabecera aplicado al subtotal). */
@@ -44,6 +49,7 @@ export function computeTotals(
lines: readonly GridLine[],
rates: ReadonlyMap<string, number>,
discountPercent: number,
freight = 0,
): DocTotals {
const factor = 1 - discountPercent / 100;
let subtotal = 0;
@@ -59,10 +65,50 @@ export function computeTotals(
discount: round2(subtotal - net),
net: round2(net),
tax: round2(tax),
total: round2(net + tax),
freight: round2(freight),
total: round2(net + tax + freight),
};
}
// --- flete ---------------------------------------------------------------------------------
/** Flete del documento tal como se edita: el código del gasto y el importe (`FreightExpense`, `FreightAmount`). */
export function freightOf(doc: DocRecord | undefined): { expense: string; amount: number; editable: boolean } {
const rows = (doc?.[FREIGHT_COLLECTION] as DocRecord[] | undefined) ?? [];
return {
expense: rows[0]?.['ExpenseCode'] === undefined || rows[0]?.['ExpenseCode'] === null ? '' : String(rows[0]['ExpenseCode']),
amount: round2(rows.reduce((sum, r) => sum + num(r['LineTotal']), 0)),
// con varios gastos adicionales (cargados en SAP) el flete solo se muestra: no se puede reducir a una fila
editable: rows.length <= 1,
};
}
const FREIGHT_KEYS = ['FreightExpense', 'FreightAmount'] as const;
const withoutFreight = (r: DocRecord): DocRecord => Object.fromEntries(Object.entries(r).filter(([k]) => !(FREIGHT_KEYS as readonly string[]).includes(k)));
/**
* Convierte los campos del flete del formulario (`FreightExpense`, `FreightAmount`) en la colección
* `DocumentAdditionalExpenses` que espera SAP. `expense` es el código elegido o, si no eligió, el
* de flete por defecto. Al crear, sin importe no se envía nada; al modificar solo si cambió (con
* `replace`, porque SAP reemplaza la colección entera) y un importe 0 quita el flete.
*/
export function freightPayload(
body: DocRecord,
header: DocRecord,
expense: string,
mode: 'create' | 'update',
hadFreight = false,
): { body: DocRecord; replace: boolean } {
const touched = FREIGHT_KEYS.some((k) => k in body);
if (mode === 'update' && !touched) return { body, replace: false };
const amount = round2(num(header['FreightAmount']));
const code = isEmpty(header['FreightExpense']) ? expense : String(header['FreightExpense']);
const rest = withoutFreight(body);
if (amount > 0 && code !== '') return { body: { ...rest, [FREIGHT_COLLECTION]: [{ ExpenseCode: Number(code), LineTotal: amount }] }, replace: true };
if (mode === 'update' && hadFreight) return { body: { ...rest, [FREIGHT_COLLECTION]: [] }, replace: true };
return { body: rest, replace: false };
}
// --- guardado -----------------------------------------------------------------------------
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
@@ -0,0 +1,27 @@
/**
* Rutas de menú que son pantallas de documento. Es la única parte que el enrutador necesita al
* arrancar; la configuración completa de cada pantalla (`document-screens.ts`) se carga en
* diferido al abrir una. Un test comprueba que esta lista coincide con `DOCUMENT_SCREENS`.
*/
export const DOCUMENT_PATHS: readonly string[] = [
// Ventas
'/ventas/ofertas',
'/ventas/ordenes',
'/ventas/entrega',
'/ventas/facturas',
'/ventas/devoluciones',
'/ventas/notas-credito',
// Compras
'/compras/solicitudes',
'/compras/ofertas',
'/compras/ordenes',
'/compras/recepcion',
'/compras/facturas',
'/compras/devoluciones',
'/compras/notas-credito',
// Inventario
'/inventario/entradas',
'/inventario/salidas',
'/inventario/transferencias',
'/inventario/solicitudes-traslado',
];
+13 -3
View File
@@ -1,13 +1,16 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, booleanAttribute, input } from '@angular/core';
/**
* Barra de herramientas del documento (Nueva, Buscar, navegación...). Los botones son
* `<button class="tb">` con un `<sbo-icon>`; `<span class="tb-sep">` separa grupos.
*
* Con `bleed` ocupa todo el ancho del área de contenido (como en SBO y el mockup): sin el margen de
* 16 px de la página ni bordes laterales, pegada a la barra superior.
*/
@Component({
selector: 'sbo-toolbar',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { role: 'toolbar' },
host: { role: 'toolbar', '[class.bleed]': 'bleed()' },
template: '<ng-content />',
styles: `
:host {
@@ -20,6 +23,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
border: 1px solid var(--line);
border-bottom-width: 0;
}
:host(.bleed) {
margin: -16px -16px 16px;
border-width: 0 0 1px;
border-radius: 0;
}
`,
})
export class Toolbar {}
export class Toolbar {
readonly bleed = input(false, { transform: booleanAttribute });
}