feat: moneda y tipo de cambio del documento (debajo del Nº de referencia) y ajustes de moneda local de la empresa

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 14:40:14 -05:00
1 parent a74c19a139
commit b85383dda1
11 files changed
+229 -9

No files matched your search

+3 -3
View File
@@ -9,15 +9,15 @@ describe('CompanyService', () => {
};
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 { service, request } = make({ RoundingMethod: 'tYES', AccountSegmentsSeparator: '.', LocalCurrency: 'SOL', DefaultBankAccount: '192-1153365-0-50' });
const settings = await service.settings();
expect(settings).toEqual({ roundingEnabled: true, accountSegmentsSeparator: '.' });
expect(settings).toEqual({ roundingEnabled: true, accountSegmentsSeparator: '.', localCurrency: 'SOL' });
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: '-' });
expect(await service.settings()).toEqual({ roundingEnabled: false, accountSegmentsSeparator: '-', localCurrency: '' });
});
it('recuerda la respuesta un rato y luego la vuelve a pedir', async () => {
+2
View File
@@ -8,6 +8,7 @@ const TTL_MS = 10 * 60 * 1000;
interface AdminInfo {
RoundingMethod?: string;
AccountSegmentsSeparator?: string | null;
LocalCurrency?: string | null;
}
/**
@@ -26,6 +27,7 @@ export class CompanyService {
const value: CompanySettings = {
roundingEnabled: res.data?.RoundingMethod === 'tYES',
accountSegmentsSeparator: res.data?.AccountSegmentsSeparator?.trim() || '-',
localCurrency: res.data?.LocalCurrency?.trim() || '',
};
this.cached = { at: now, value };
return value;
+7
View File
@@ -77,4 +77,11 @@ describe('recursos del Service Layer', () => {
expect(r.actions).toEqual(['Cancel']);
expect(r.write).toBe('/finanzas/asientos');
});
it('el tipo de cambio es una acción de solo consulta y las formas de envío y de pago son catálogos', () => {
const rate = SL_RESOURCE_MAP.get('SBOBobService_GetCurrencyRate')!;
expect(rate.methods).toEqual(['POST']);
expect(rate.write).toBeNull();
for (const name of ['ShippingTypes', 'WizardPaymentMethods']) expect(SL_RESOURCE_MAP.get(name)!.methods, name).toEqual(['GET']);
});
});
+5
View File
@@ -110,6 +110,11 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('AccountSegmentations', 'number'),
// Series de numeración válidas de un tipo de documento.
queryAction('SeriesService_GetDocumentSeries'),
// Tipo de cambio de una moneda en una fecha (para documentos en moneda extranjera): solo consulta.
queryAction('SBOBobService_GetCurrencyRate'),
// Logística y finanzas de los documentos: formas de envío y métodos de pago.
catalog('ShippingTypes', 'number'),
catalog('WizardPaymentMethods', 'string'),
// Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto.
catalog('AdditionalExpenses', 'number'),
];
@@ -14,14 +14,18 @@ export class CompanySettingsService {
private readonly http = inject(HttpClient);
private loading: Promise<void> | null = null;
private readonly rounding = signal(false);
private readonly local = signal('');
/** La empresa usa redondeo en los documentos (`OADM.RoundMthd`): solo entonces se ofrece la fila «Redondeo». */
readonly roundingEnabled = this.rounding.asReadonly();
/** Moneda local de la empresa (vacía mientras no se cargan los ajustes). */
readonly localCurrency = this.local.asReadonly();
ensureLoaded(): Promise<void> {
this.loading ??= firstValueFrom(this.http.get<CompanySettings>(`${API_URL}/company/settings`))
.then((s) => {
this.rounding.set(s.roundingEnabled);
this.local.set(s.localCurrency);
setAccountSegmentSeparator(s.accountSegmentsSeparator);
})
.catch(() => {
+5
View File
@@ -36,6 +36,11 @@ export class SlDocumentApi {
return firstValueFrom(this.http.post<unknown>(`${this.url(resource, docEntry)}/${action}`, {}));
}
/** Tipo de cambio de una moneda en una fecha (`SBOBobService_GetCurrencyRate`): cuántas unidades de la moneda local vale una de ella. */
currencyRate(currency: string, date: string): Promise<number> {
return firstValueFrom(this.http.post<number>(`${API_URL}/sl/SBOBobService_GetCurrencyRate`, { Currency: currency, Date: date }));
}
/** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */
create(resource: string, body: DocRecord): Promise<DocRecord> {
return firstValueFrom(this.http.post<DocRecord>(this.url(resource), body));
@@ -0,0 +1,121 @@
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('moneda y tipo de cambio del documento', () => {
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();
};
async function open() {
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 loaded = TestBed.inject(CompanySettingsService).ensureLoaded();
ctrl.expectOne('/api/company/settings').flush({ roundingEnabled: false, accountSegmentsSeparator: '-', localCurrency: 'SOL' });
await loaded;
fixture = TestBed.createComponent(DocumentFormPage);
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [] });
ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush({ value: [] });
ctrl.expectOne((r) => r.url === '/api/sl/Currencies').flush({ value: [{ Code: 'SOL', Name: 'Sol' }, { Code: 'USD', Name: 'Dolar' }] });
await tick();
}
const save = async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
return ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/Orders');
};
beforeEach(() => TestBed.resetTestingModule());
it('la fila va debajo del Nº de referencia, con la moneda local primero', async () => {
await open();
const left = page().layout.header.left as { type: string; key?: string }[];
const at = left.findIndex((f) => f.key === 'NumAtCard');
expect(left[at + 1].type).toBe('currency');
expect(page().currencyOptions().map((o: { value: string; label: string }) => o.label)).toEqual(['Moneda local', 'USD · Dolar']);
expect(page().currencyValue()).toBe('SOL');
expect(page().isLocalCurrency()).toBe(true);
});
it('elegir otra moneda pide su tipo de cambio de la fecha del documento y se puede corregir', async () => {
await open();
page().onCurrency('USD');
await tick();
const req = ctrl.expectOne('/api/sl/SBOBobService_GetCurrencyRate');
expect(req.request.body).toEqual({ Currency: 'USD', Date: page().header().DocDate });
req.flush(3.431);
await tick();
expect(page().header().DocRate).toBe(3.431);
expect(page().isLocalCurrency()).toBe(false);
page().setHeader('DocRate', 3.5);
expect(page().header().DocRate).toBe(3.5);
});
it('volver a la moneda local limpia el tipo de cambio', async () => {
await open();
page().onCurrency('USD');
ctrl.expectOne('/api/sl/SBOBobService_GetCurrencyRate').flush(3.4);
await tick();
page().onCurrency('SOL');
expect(page().header().DocRate).toBe('');
expect(page().isLocalCurrency()).toBe(true);
});
it('crea en moneda extranjera enviando moneda y tipo de cambio, y en la local no envía ninguno', async () => {
await open();
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 10, LineTotal: 10 }]);
page().onCurrency('USD');
ctrl.expectOne('/api/sl/SBOBobService_GetCurrencyRate').flush(3.431);
await tick();
let req = await save();
expect(req.request.body).toMatchObject({ DocCurrency: 'USD', DocRate: 3.431 });
req.flush({ DocEntry: 1, DocNum: 1 });
await tick();
TestBed.resetTestingModule();
await open();
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 10, LineTotal: 10 }]);
req = await save();
expect(req.request.body).not.toHaveProperty('DocCurrency');
expect(req.request.body).not.toHaveProperty('DocRate');
});
it('el socio con otra moneda hace que el documento la tome', async () => {
await open();
page().onPartner({ CardCode: 'C2', CardName: 'EXPORT', Currency: 'USD' });
ctrl.expectOne('/api/sl/SBOBobService_GetCurrencyRate').flush(3.4);
await tick();
expect(page().currencyValue()).toBe('USD');
page().onPartner({ CardCode: 'C3', CardName: 'TODAS', Currency: '##' });
expect(page().currencyValue()).toBe('USD'); // «todas las monedas» no cambia nada
});
it('en un documento guardado la moneda no se cambia', async () => {
await open();
const f = page().layout.header.left.find((x: { type: string }) => x.type === 'currency');
expect(page().locked(f)).toBe(false);
});
});
@@ -248,6 +248,25 @@
}
</div>
}
@case ('currency') {
<sbo-field [label]="f.label">
<sbo-select
width="170px"
ariaLabel="Moneda del documento"
[options]="currencyOptions()"
[readonly]="readOnly() || locked(f)"
[value]="currencyValue()"
(valueChange)="onCurrency('' + ($event ?? ''))"
/>
<sbo-number
ariaLabel="Tipo de cambio"
[decimals]="6"
[readonly]="readOnly() || locked(f) || isLocalCurrency()"
[value]="isLocalCurrency() ? null : +str(header()['DocRate'])"
(valueChange)="setHeader('DocRate', $event ?? '')"
/>
</sbo-field>
}
@case ('rounding') {
@if (roundingEnabled()) {
<sbo-field [label]="f.label">
@@ -6,7 +6,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
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 { formatNumber, todayIso } from '../../core/format';
import { CompanySettingsService } from '../../core/company/company-settings';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
@@ -34,7 +34,7 @@ import {
validateLayout,
} from './document-model';
import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasExpenses, hasFolio, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasSeries, headerKeysOf, keysOfFields, 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 { OBJECT_TYPES } from './document-objects';
@@ -43,7 +43,7 @@ import { ITEMS_MULTI_ADD, LINE_COLUMNS } from './line-columns';
import { RowsTable } from '../../shared/grid/rows-table';
import type { DetailColumn } from '../../shared/grid/grid.types';
const today = (): string => new Date().toISOString().slice(0, 10);
const today = (): string => todayIso();
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate', 'TaxDate', 'DueDate']);
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
@@ -89,7 +89,8 @@ export class DocumentFormPage {
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;
private readonly settings = inject(CompanySettingsService);
protected readonly roundingEnabled = this.settings.roundingEnabled;
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
@@ -104,6 +105,7 @@ export class DocumentFormPage {
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
private readonly hasExpenses = hasExpenses(this.layout);
private readonly hasCurrency = hasCurrency(this.layout);
private pick(doc: DocRecord | undefined): DocRecord {
return pickHeader(doc, this.headerKeys, this.blankHeader);
}
@@ -136,6 +138,39 @@ export class DocumentFormPage {
() => new Map((this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => [String(e['ExpensCode']), text(e['Name'])])),
);
// --- moneda y tipo de cambio ---------------------------------------------------------------
private readonly currenciesResource = httpResource<SlListResponse<DocRecord>>(() =>
this.hasCurrency ? { url: `${API_URL}/sl/Currencies`, params: { $select: 'Code,Name', $top: 100 } } : undefined,
);
/** Moneda local primero («Moneda local») y después las demás. */
protected readonly currencyOptions = computed(() => {
const local = this.settings.localCurrency();
const others = (this.currenciesResource.hasValue() ? this.currenciesResource.value().value : [])
.filter((c) => text(c['Code']) !== local)
.map((c) => ({ value: text(c['Code']), label: `${text(c['Code'])} · ${text(c['Name'])}` }));
return [{ value: local, label: 'Moneda local' }, ...others];
});
protected readonly currencyValue = computed(() => text(this.header()['DocCurrency']) || this.settings.localCurrency());
/** El documento está en la moneda local (o la empresa aún no informa cuál es): no lleva tipo de cambio. */
protected readonly isLocalCurrency = computed(() => !text(this.header()['DocCurrency']) || this.currencyValue() === this.settings.localCurrency());
protected onCurrency(code: string): void {
this.header.update((h) => ({ ...h, DocCurrency: code, DocRate: '' }));
this.message.set(null);
if (code !== this.settings.localCurrency()) void this.fetchRate(code);
}
/** El tipo de cambio de la moneda en la fecha del documento; se puede corregir a mano. */
private async fetchRate(currency: string): Promise<void> {
try {
const rate = await this.api.currencyRate(currency, text(this.header()['DocDate']) || today());
if (text(this.header()['DocCurrency']) === currency) this.header.update((h) => ({ ...h, DocRate: rate }));
} catch (e) {
this.message.set({ kind: 'warn', text: `No se pudo obtener el tipo de cambio de ${currency}: ${apiErrorMessage(e)}` });
}
}
/** Filas de gastos adicionales; se reinician al cargar o recargar el documento. */
protected readonly expenses = linkedSignal<ExpenseRow[]>(() => expenseRowsOf(this.doc()));
protected readonly expenseColumns: readonly DetailColumn[] = [
@@ -393,7 +428,7 @@ export class DocumentFormPage {
baseFilter: `CardType eq '${this.partner?.type ?? 'cCustomer'}'`,
orderby: 'CardCode',
columns: this.layout.lookups?.partner ?? PARTNER_COLUMNS,
extraFields: ['FederalTaxID'],
extraFields: ['FederalTaxID', 'Currency'],
};
protected str = text;
@@ -401,6 +436,8 @@ export class DocumentFormPage {
private readonly lockedKeys = new Set(this.layout.lockedKeys ?? []);
/** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */
protected locked(f: HeaderField): boolean {
// la moneda de un documento ya creado no se cambia
if (f.type === 'currency' && !this.isNew()) return true;
// el tipo (artículo / servicio) no se cambia en un documento ya creado
if (f.type === 'select' && f.key === 'DocType' && !this.isNew()) return true;
return this.lockedKeys.size > 0 && (keysOfFields([f]).some((k) => this.lockedKeys.has(k)) || (f.type === 'computed' && this.lockedKeys.has(f.source)));
@@ -442,6 +479,8 @@ export class DocumentFormPage {
if (key === 'DocType' && this.layout.service) return this.onDocType(String(value));
this.header.update((h) => ({ ...h, [key]: value }));
this.message.set(null);
// otra fecha del documento, otro tipo de cambio
if (key === 'DocDate' && this.isNew() && !this.isLocalCurrency()) void this.fetchRate(this.currencyValue());
}
/** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */
@@ -494,6 +533,9 @@ export class DocumentFormPage {
protected onPartner(row: Record<string, unknown>): void {
this.partnerName.set(text(row['CardName']));
this.partnerTaxId.set(text(row['FederalTaxID']));
// el documento toma la moneda del socio (si es una sola y no es la local), como en SAP
const currency = text(row['Currency']);
if (this.isNew() && this.hasCurrency && currency && currency !== '##' && currency !== this.currencyValue() && this.isLocalCurrency()) this.onCurrency(currency);
// elegido el socio, el documento nuevo arranca con una línea lista para escribir
if (this.isNew() && !this.readOnly() && this.lines().length === 0) this.lines.set([this.newLine()]);
}
@@ -535,6 +577,11 @@ export class DocumentFormPage {
const d = this.doc();
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
// en la moneda local no se envía moneda ni tipo de cambio: SAP usa los suyos
if (this.isLocalCurrency()) {
delete header['DocCurrency'];
delete header['DocRate'];
}
// sin redondeo marcado no se envía nada: SAP calcula el suyo
if (header['Rounding'] !== 'tYES') {
delete header['Rounding'];
@@ -1,3 +1,4 @@
import { todayIso } from '../../core/format';
import type { GridColumn, GridDetail, GridLine } from '../../shared/grid/grid.types';
import type { ListColumn } from '../../shared/list/list.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
@@ -59,6 +60,8 @@ export type HeaderField =
| { type: 'expenses'; label: string }
/** Redondeo del documento (`Rounding` y `RoundingDiffAmount`): solo se ofrece si la empresa usa redondeo (`OADM.RoundMthd`). */
| { type: 'rounding'; label: string }
/** Moneda del documento (`DocCurrency`) y su tipo de cambio (`DocRate`): el de la fecha del documento si no es la local. */
| { type: 'currency'; label: string }
| { type: 'computed'; label: string; source: ComputedSource };
/** Las listas de valores (ventanas modales) de un formulario. */
@@ -114,7 +117,7 @@ export interface DocumentLayout {
export const SERVICE_DOC_TYPE = 'dDocument_Service';
export const ITEMS_DOC_TYPE = 'dDocument_Items';
export const today = (): string => new Date().toISOString().slice(0, 10);
export const today = (): string => todayIso();
// --- catálogo de campos ---------------------------------------------------------------------
@@ -279,6 +282,7 @@ export function marketingLayout(opts: {
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
FIELDS.numAtCard,
{ type: 'currency', label: 'Moneda / Tipo de cambio' },
...(opts.identity ? [] : [FIELDS.indicator]),
FIELDS.project,
...(opts.left ?? []),
@@ -341,6 +345,8 @@ export const keysOfFields = (fields: readonly HeaderField[]): string[] =>
return [];
case 'rounding':
return ['Rounding', 'RoundingDiffAmount'];
case 'currency':
return ['DocCurrency', 'DocRate'];
default:
return [f.key];
}
@@ -365,4 +371,6 @@ export const hasSeries = (layout: DocumentLayout): boolean => allFields(layout).
export const hasExpenses = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'expenses');
export const hasCurrency = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'currency');
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio' || (f.type === 'group' && f.cells.some((c) => c.key === 'FolioNumber')));
+2
View File
@@ -5,6 +5,8 @@
export interface CompanySettings {
/** La empresa usa redondeo en los documentos (`OADM.RoundMthd`, `RoundingMethod` en el Service Layer). */
roundingEnabled: boolean;
/** Moneda local de la empresa (`LocalCurrency`): la de los documentos que no llevan otra. */
localCurrency: string;
/** Separador de los segmentos de un código de cuenta (`AccountSegmentsSeparator`). */
accountSegmentsSeparator: string;
}