feat: redondeo en el pie de los documentos de ventas y compras (visible según OADM.RoundMthd) y endpoint /api/company/settings

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 12:53:23 -05:00
1 parent 6c2cb30d63
commit e9e3ba03e5
19 files changed
+270 -17

No files matched your search

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { CompanyService } from './company.service.js';
/** Ajustes de la empresa de SAP para la interfaz: cualquier sesión puede consultarlos. */
@Controller('company')
export class CompanyController {
constructor(private readonly company: CompanyService) {}
@Get('settings')
settings() {
return this.company.settings();
}
}
@@ -0,0 +1,31 @@
import { vi } from 'vitest';
import type { SapClientService } from '../sap/sap-client.service.js';
import { CompanyService } from './company.service.js';
describe('CompanyService', () => {
const make = (data: unknown) => {
const request = vi.fn(async () => ({ data }));
return { service: new CompanyService({ request } as unknown as SapClientService), request };
};
it('entrega solo el redondeo y el separador, sin el resto de la información de la empresa', async () => {
const { service, request } = make({ RoundingMethod: 'tYES', AccountSegmentsSeparator: '.', DefaultBankAccount: '192-1153365-0-50' });
const settings = await service.settings();
expect(settings).toEqual({ roundingEnabled: true, accountSegmentsSeparator: '.' });
expect(request).toHaveBeenCalledWith({ method: 'POST', path: 'CompanyService_GetAdminInfo', body: {} });
});
it('sin redondeo ni separador usa los valores por defecto', async () => {
const { service } = make({ RoundingMethod: 'tNO', AccountSegmentsSeparator: null });
expect(await service.settings()).toEqual({ roundingEnabled: false, accountSegmentsSeparator: '-' });
});
it('recuerda la respuesta un rato y luego la vuelve a pedir', async () => {
const { service, request } = make({ RoundingMethod: 'tYES' });
await service.settings(1000);
await service.settings(1000 + 60_000);
expect(request).toHaveBeenCalledTimes(1);
await service.settings(1000 + 11 * 60_000);
expect(request).toHaveBeenCalledTimes(2);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import type { CompanySettings } from '@basic-erp/sap-types';
import { SapClientService } from '../sap/sap-client.service.js';
/** Cuánto se recuerdan los ajustes de la empresa: cambian casi nunca. */
const TTL_MS = 10 * 60 * 1000;
interface AdminInfo {
RoundingMethod?: string;
AccountSegmentsSeparator?: string | null;
}
/**
* Ajustes de la empresa que necesita la interfaz. Se leen de `CompanyService_GetAdminInfo` y solo se entregan los
* campos de `CompanySettings`: esa respuesta trae mucho más (cuentas bancarias, carpetas) que no debe salir.
*/
@Injectable()
export class CompanyService {
private cached: { at: number; value: CompanySettings } | null = null;
constructor(private readonly sap: SapClientService) {}
async settings(now = Date.now()): Promise<CompanySettings> {
if (this.cached && now - this.cached.at < TTL_MS) return this.cached.value;
const res = await this.sap.request<AdminInfo>({ method: 'POST', path: 'CompanyService_GetAdminInfo', body: {} });
const value: CompanySettings = {
roundingEnabled: res.data?.RoundingMethod === 'tYES',
accountSegmentsSeparator: res.data?.AccountSegmentsSeparator?.trim() || '-',
};
this.cached = { at: now, value };
return value;
}
}
+4 -2
View File
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module.js';
import { CompanyController } from '../company/company.controller.js';
import { CompanyService } from '../company/company.service.js';
import { PermissionsModule } from '../permissions/permissions.module.js';
import { SapModule } from '../sap/sap.module.js';
import { SlProxyController } from './sl-proxy.controller.js';
@@ -7,7 +9,7 @@ import { SlProxyService } from './sl-proxy.service.js';
@Module({
imports: [SapModule, PermissionsModule, AuditModule],
controllers: [SlProxyController],
providers: [SlProxyService],
controllers: [SlProxyController, CompanyController],
providers: [SlProxyService, CompanyService],
})
export class SlModule {}
@@ -0,0 +1,32 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import type { CompanySettings } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
import { setAccountSegmentSeparator } from '../sl/account-segments';
/**
* Ajustes de la empresa de SAP (`GET /api/company/settings`): si usa redondeo en los documentos y cómo se separan
* los segmentos de una cuenta. Se piden una vez por sesión; mientras no llegan, sin redondeo y con el separador `-`.
*/
@Injectable({ providedIn: 'root' })
export class CompanySettingsService {
private readonly http = inject(HttpClient);
private loading: Promise<void> | null = null;
private readonly rounding = signal(false);
/** La empresa usa redondeo en los documentos (`OADM.RoundMthd`): solo entonces se ofrece la fila «Redondeo». */
readonly roundingEnabled = this.rounding.asReadonly();
ensureLoaded(): Promise<void> {
this.loading ??= firstValueFrom(this.http.get<CompanySettings>(`${API_URL}/company/settings`))
.then((s) => {
this.rounding.set(s.roundingEnabled);
setAccountSegmentSeparator(s.accountSegmentsSeparator);
})
.catch(() => {
this.loading = null; // se reintenta en la próxima pantalla
});
return this.loading;
}
}
+7 -2
View File
@@ -4,9 +4,14 @@ import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
/** Separador de los segmentos del código de cuenta (`AccountSegmentsSeparator` de SAP; el Service Layer no lo ofrece a una sesión común). */
/** Separador de los segmentos del código de cuenta por defecto; el de la empresa (`AccountSegmentsSeparator`) lo trae `/api/company/settings`. */
export const ACCOUNT_SEGMENT_SEPARATOR = '-';
/** Separador de los segmentos que usa la empresa (hasta que se cargan sus ajustes, el de por defecto). */
const separator = signal(ACCOUNT_SEGMENT_SEPARATOR);
export const accountSegmentSeparator = (): string => separator();
export const setAccountSegmentSeparator = (value: string): void => separator.set(value || ACCOUNT_SEGMENT_SEPARATOR);
/**
* Parte un código con formato en sus segmentos y los une con el separador (`8911111010` con tamaños 6, 2 y 2 →
* `891111-10-10`). Si no hay segmentos, o sus tamaños no suman el largo del código, se devuelve tal cual: así un
@@ -27,7 +32,7 @@ export function formatSegments(code: string, sizes: readonly number[], separator
const segmentSizes = signal<readonly number[]>([]);
/** Código con formato de una cuenta segmentada, con sus separadores. Lee una señal: las vistas se actualizan al cargar los segmentos. */
export const formatAccountCode = (code: string): string => formatSegments(code, segmentSizes());
export const formatAccountCode = (code: string): string => formatSegments(code, segmentSizes(), separator());
/** Carga una vez los tamaños de los segmentos del código de cuenta (`AccountSegmentations`). */
@Injectable({ providedIn: 'root' })
+2 -2
View File
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
import type { ListRow } from '../../shared/list/list.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { API_URL } from '../api';
import { ACCOUNT_SEGMENT_SEPARATOR } from './account-segments';
import { accountSegmentSeparator } from './account-segments';
import { combineFilters, escapeODataString } from './odata';
/**
@@ -24,7 +24,7 @@ export class SlLookupApi {
const typed = code.trim();
const quote = (f: string, v: string): string => `${f} eq '${escapeODataString(v)}'`;
// el código con formato se puede escribir con o sin los separadores de segmento
const variants = [...new Set([typed, typed.split(ACCOUNT_SEGMENT_SEPARATOR).join('')])];
const variants = [...new Set([typed, typed.split(accountSegmentSeparator()).join('')])];
const match = numeric
? `${keyField} eq ${typed}`
: [quote(keyField, typed), ...(lookup.alsoMatch ?? []).flatMap((f) => variants.map((v) => quote(f, v)))].join(' or ');
@@ -214,6 +214,25 @@
}
</div>
}
@case ('rounding') {
@if (roundingEnabled()) {
<sbo-field [label]="f.label">
<sbo-checkbox
ariaLabel="Redondear el total del documento"
[readonly]="readOnly() || locked(f)"
[value]="header()['Rounding'] === 'tYES'"
(valueChange)="setHeader('Rounding', $event ? 'tYES' : 'tNO')"
/>
<sbo-number
ariaLabel="Importe del redondeo"
[decimals]="2"
[readonly]="readOnly() || locked(f) || header()['Rounding'] !== 'tYES'"
[value]="+str(header()['RoundingDiffAmount'])"
(valueChange)="setHeader('RoundingDiffAmount', $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, expenses: 0, total: 238 });
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, expenses: 0, rounding: 0, total: 238 });
expect(buttonText()).toContain('Actualizar');
});
@@ -7,8 +7,9 @@ import type { SlListResponse } from '@basic-erp/sap-types';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { CompanySettingsService } from '../../core/company/company-settings';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, DateField, LookupField, NumberField, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
import { CheckboxField, CheckboxInput, 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';
@@ -71,7 +72,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
imports: [CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, 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; }
@@ -87,6 +88,8 @@ export class DocumentFormPage {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(SlDocumentApi);
/** La empresa usa redondeo (`OADM.RoundMthd`): solo entonces se ofrece la fila «Redondeo». */
protected readonly roundingEnabled = inject(CompanySettingsService).roundingEnabled;
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
@@ -269,7 +272,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), this.expenses()),
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), this.expenses(), this.header()['Rounding'] === 'tYES' ? Number(this.header()['RoundingDiffAmount'] ?? 0) : 0),
);
protected readonly fmt = (n: number): string => formatNumber(n, 2);
@@ -512,6 +515,11 @@ export class DocumentFormPage {
const d = this.doc();
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
// sin redondeo marcado no se envía nada: SAP calcula el suyo
if (header['Rounding'] !== 'tYES') {
delete header['Rounding'];
delete header['RoundingDiffAmount'];
}
const body = createPayload(header, this.lines(), this.spec());
if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
const created = await this.api.create(this.screen.resource, body);
@@ -233,7 +233,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', 'Rounding', 'RoundingDiffAmount']);
});
it('los demás documentos no cambian cuando uno elige columnas', () => {
@@ -44,6 +44,8 @@ export type HeaderField =
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
/** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */
| { type: 'expenses'; label: string }
/** Redondeo del documento (`Rounding` y `RoundingDiffAmount`): solo se ofrece si la empresa usa redondeo (`OADM.RoundMthd`). */
| { type: 'rounding'; label: string }
| { type: 'computed'; label: string; source: ComputedSource };
/** Las listas de valores (ventanas modales) de un formulario. */
@@ -263,11 +265,12 @@ export function marketingLayout(opts: {
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
{ type: 'computed', label: 'Gastos adicionales', source: 'expenses' },
{ type: 'computed', label: 'Impuesto', source: 'tax' },
{ type: 'rounding', label: 'Redondeo' },
{ type: 'computed', label: 'Total del documento', source: 'total' },
],
},
columns,
newHeader: () => ({ DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
newHeader: () => ({ DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), DiscountPercent: 0, Rounding: 'tNO', RoundingDiffAmount: 0 }),
newLine: newDocumentLine,
recalc: recalcLine,
service: {
@@ -301,6 +304,8 @@ export const keysOfFields = (fields: readonly HeaderField[]): string[] =>
return [f.prefixKey, f.numberKey];
case 'expenses':
return [];
case 'rounding':
return ['Rounding', 'RoundingDiffAmount'];
default:
return [f.key];
}
@@ -39,12 +39,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, expenses: 0, total: 338 });
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, expenses: 0, rounding: 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, expenses: 0, total: 214.2 });
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, expenses: 0, rounding: 0, total: 214.2 });
});
it('un código de impuesto desconocido no suma impuesto', () => {
@@ -52,7 +52,7 @@ describe('computeTotals', () => {
});
it('sin líneas, todo en cero', () => {
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, expenses: 0, total: 0 });
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, expenses: 0, rounding: 0, total: 0 });
});
});
@@ -35,6 +35,8 @@ export interface DocTotals {
/** Total antes de impuestos. */
net: number;
tax: number;
/** Redondeo del documento, si está marcado. */
rounding: number;
total: number;
}
@@ -52,6 +54,7 @@ export function computeTotals(
rates: ReadonlyMap<string, number>,
discountPercent: number,
expenseRows: readonly ExpenseRow[] = [],
rounding = 0,
): DocTotals {
const factor = 1 - discountPercent / 100;
let subtotal = 0;
@@ -74,7 +77,8 @@ export function computeTotals(
net: round2(net),
tax: round2(tax),
expenses: round2(expenses),
total: round2(net + tax + expenses),
rounding: round2(rounding),
total: round2(net + tax + expenses + rounding),
};
}
@@ -0,0 +1,88 @@
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 { CompanySettingsService } from '../../core/company/company-settings';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
describe('redondeo en el formulario', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
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();
};
/** Abre un documento nuevo; `roundingEnabled` es lo que la empresa responde en sus ajustes (`OADM.RoundMthd`). */
async function open(roundingEnabled: boolean) {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'Orders')!;
const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})) };
await TestBed.configureTestingModule({
imports: [DocumentFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
const settings = TestBed.inject(CompanySettingsService);
const loaded = settings.ensureLoaded();
ctrl.expectOne('/api/company/settings').flush({ roundingEnabled, accountSegmentsSeparator: '-' });
await loaded;
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({ value: [] });
await tick();
}
const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
beforeEach(() => TestBed.resetTestingModule());
it('si la empresa redondea por moneda aparece la fila Redondeo', async () => {
await open(true);
expect(labels()).toContain('Redondeo');
});
it('si el redondeo es por documento la fila no se ve, pero el campo sigue en el documento', async () => {
await open(false);
expect(labels()).not.toContain('Redondeo');
expect(page().header()).toMatchObject({ Rounding: 'tNO', RoundingDiffAmount: 0 });
});
it('el redondeo marcado se suma al total y se envía al crear', async () => {
await open(true);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, TaxCode: 'IVA', LineTotal: 100 }]);
page().setHeader('Rounding', 'tYES');
page().setHeader('RoundingDiffAmount', 0.4);
await tick();
expect(page().totals()).toMatchObject({ rounding: 0.4, total: 119.4 });
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(req.request.body).toMatchObject({ Rounding: 'tYES', RoundingDiffAmount: 0.4 });
req.flush({ DocEntry: 10, DocNum: 10 });
await tick();
});
it('sin marcar no se suma ni se envía', async () => {
await open(true);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, TaxCode: 'IVA', LineTotal: 100 }]);
page().setHeader('RoundingDiffAmount', 0.4); // el importe solo cuenta con el redondeo marcado
await tick();
expect(page().totals()).toMatchObject({ rounding: 0, total: 119 });
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const body = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record<string, unknown>;
expect(body).not.toHaveProperty('Rounding');
expect(body).not.toHaveProperty('RoundingDiffAmount');
});
});
+2
View File
@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CompanySettingsService } from '../core/company/company-settings';
import { AccountCodesService } from '../core/sl/account-codes';
import { AccountSegmentsService } from '../core/sl/account-segments';
import { AuthService } from '../core/auth/auth.service';
@@ -27,6 +28,7 @@ export class MainLayout {
constructor() {
void this.segments.ensureLoaded();
void inject(CompanySettingsService).ensureLoaded();
}
private readonly collapsed = signal<ReadonlySet<string>>(new Set());