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

@@ -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'];